| name | debug-verbose |
| description | Evidence-based debugging via targeted verbose instrumentation. Apply at the first sign of any non-obvious bug — before theorising. Grows with each bug fixed in this project. |
| user_invocable | true |
| argument | Optional: short description of the bug or area to instrument |
Verbose Debug Instrumentation
Core principle: stop theorising, start observing. The first step for any non-trivial bug is to instrument the code so the actual runtime sequence is printed to stdout, then reproduce with manual testing and read what happened. Fix from evidence, not assumptions.
When to apply (proactively, without being asked)
- Behaviour differs from what the code appears to do
- Event-driven / asynchronous code (timers, signals, focus events, callbacks)
- Something is called unexpectedly, or not called at all
- A guard/condition seems correct but isn't firing
- Third-party framework (Qt, etc.) is involved and may have side effects
How to instrument
1. Identify the execution spine
Map the path from trigger to outcome. For every node on that path add a print:
trigger → A() → B() → [condition] → C() ← expected
↘ D() ← what actually happens?
2. What to print at each node
| Node type | Print |
|---|
| Entry to function | function name + key arguments + type(self).__name__ |
| State that the condition reads | the exact values used in the if |
| Timestamps for time-based guards | time.monotonic() before AND inside the guard |
| Focus / visibility / flag checks | hasFocus(), isVisible(), flags() |
| Async callbacks (timers, slots) | "fired" + whether preconditions hold |
| Exit paths | which branch was taken, what was returned |
| Unexpected call sites | traceback.format_stack()[:-1] — always include this for "who called me?" questions |
3. Use print, not logging
logging requires configuration. print goes to stdout unconditionally — exactly what you need when the app is run from a terminal.
4. Prefix every line
Use a consistent tag like [MODULE] so output is grep-able and doesn't get lost in Qt warnings:
print(f"[LABEL] focusOutEvent: elapsed={elapsed:.4f}s isVisible={self.isVisible()}")
5. Include call stacks at "unexpected" sites
Any function that should only be called from specific places should print its caller when debugging:
import traceback
for line in traceback.format_stack()[:-1]:
print(f"[TAG] {line.strip()}")
This is what revealed the minimap as the culprit below.
Template — event-driven method instrumentation
def some_event_handler(self, event):
import time, traceback
t = time.monotonic()
start = getattr(self, '_start_time', 0)
print(f"\n[TAG] some_event_handler:")
print(f"[TAG] key_state = {self.some_state}")
print(f"[TAG] elapsed = {t - start:.4f}s")
print(f"[TAG] condition = {self.isVisible() and (t - start) < 0.2}")
print("[TAG] caller stack:")
for line in traceback.format_stack()[:-1]:
print(f"[TAG] {line.strip()}")
Template — async/deferred callback
def _deferred_action():
import time
print(f"[TAG] _deferred_action fired — isVisible={item.isVisible()} hasFocus={item.hasFocus()}")
if not item.isVisible():
print("[TAG] ABORT — item hidden before callback ran")
return
item.do_thing()
print(f"[TAG] after do_thing — hasFocus={item.hasFocus()}")
QTimer.singleShot(0, _deferred_action)
Case study: label editor auto-closing (fixed 2026-04-22)
Symptom: double-clicking any garden item opened the inline label editor for ~110 ms then it closed by itself.
Theories entertained (wrong):
- Qt's double-click Release-2 event steals focus
_label_edit_start_time set after setFocus() so guard evaluated stale 0.0
super().focusOutEvent() clears text cursor
What instrumentation revealed (one double-click, reading stdout):
[LABEL] _give_focus() — after setFocus: hasFocus=True isVisible=True
[LABEL] focusOutEvent:
[LABEL] elapsed = 0.109000s
[LABEL] isVisible() = False ← ALREADY HIDDEN before focusOut fired
[LABEL] guard (<0.2s) = False ← guard missed because isVisible is False
[LABEL] caller stack:
[LABEL] minimap_widget.py:205 — item.setVisible(False) ← THE CULPRIT
Root cause: MinimapWidget._hide_overlay_items() iterates all scene items with ItemIgnoresTransformations and calls setVisible(False) on them — including the EditableLabel — before rendering the minimap thumbnail (~110 ms after focus was given). Hiding the item fired focusOutEvent with isVisible() = False, so the time-based guard (which checks isVisible()) never activated.
Fix (one line in minimap_widget.py): skip the scene's current focus item in _hide_overlay_items().
Lesson: the call stack in focusOutEvent pointed directly to the file and line number of the external caller. Without it, debugging would have required days of guessing.
Case study: CalloutItem re-editing immediately commits (fixed 2026-04-29)
Symptom: right-clicking an empty CalloutItem and choosing "Edit Text" did nothing — the item appeared to enter editing and immediately exit it. Items with non-empty content also failed via the context menu.
Theories entertained (wrong):
- Context menu stealing keyboard focus from the view (real, but not the root cause)
QGraphicsTextItem.setFocus() silently failing for zero-width bounding rects
_text_child.clearFocus() in _commit_edit breaking subsequent setFocus calls
What instrumentation revealed (right-click → "Edit Text" on empty callout):
[CALLOUT] start_editing: _editing=False content=''
[CALLOUT] scene focus before: CalloutItem ← parent already has scene focus
[CALLOUT] scene focus after view.setFocus(): CalloutItem ← still has it after widget focus restore
[CALLOUT] focusOutEvent on CalloutItem: _editing=True ← fires DURING _text_child.setFocus()
[CALLOUT] caller: callout_item.py:234 self._text_child.setFocus(...)
[CALLOUT] _commit_edit: _editing=True content='' ← immediately committed
[CALLOUT] _editing after setFocus: False ← editing already dead
The sequence was: context menu open → _text_child loses focus → Qt gives scene focus to the
parent CalloutItem (because ItemIsFocusable was set) → _commit_edit runs (correct at
this point). Then "Edit Text" → start_editing() → view.setFocus() restores CalloutItem
as scene focus → _text_child.setFocus() steals it → CalloutItem.focusOutEvent fires with
_editing=True → _commit_edit() immediately exits editing.
Root cause: CalloutItem had ItemIsFocusable set and a focusOutEvent that committed
the edit. Whenever _text_child.setFocus() transferred scene focus away from the parent,
focusOutEvent fired on the parent and exited editing mode synchronously — before the user
could type anything.
Fix: removed ItemIsFocusable from CalloutItem entirely. Created _CalloutTextChild
(QGraphicsTextItem subclass) that routes its own focusOutEvent → parent's
_on_text_focus_out() → _commit_edit(), and handles Escape via clearFocus(). The parent
now never holds scene focus, so focusOutEvent on the parent is never triggered during
start_editing().
Lesson: when a QGraphicsItem parent holds ItemIsFocusable AND has a child
QGraphicsTextItem, setting focus on the child fires focusOutEvent on the parent
synchronously inside setFocus(). This is the correct place to commit on "lost focus", but
it fires at the wrong time when you are entering editing. The fix is to never let the parent
hold scene focus — put all focus logic in the child subclass.
After fixing: clean up
Remove all print instrumentation before committing. The fix lives in the production code; the diagnosis lives in this skill.
How this skill grows
After every non-trivial bug fixed in this project, add a new Case study entry above with:
- Symptom (one line)
- Wrong theories (to avoid repeating them)
- The key log line(s) that revealed the truth
- Root cause (one sentence)
- Lesson learned
Over time this becomes a project-specific debugging playbook.
Case study: PNG/SVG export empty after Y-flip fix (fixed 2026-05-01)
Symptom: PNG export produced a correctly-sized image filled only with the canvas background color (#f5f5dc). No shapes visible. SVG had file content but rendered empty in browser.
Theories entertained (wrong):
- Scene items not in canvas_rect bounds
- Wrong source rect passed to scene.render()
- DPI calculation error
What instrumentation revealed: Added print(f"[EXPORT] target_rect={target_rect} isEmpty={target_rect.isEmpty()}") before scene.render(). Output: isEmpty=True.
Root cause: Previous Y-flip fix used QRectF(0, H, W, -H) as the target rect. In PyQt6, QRectF with negative height is considered empty — isEmpty() returns True. Qt's scene.render() clips to the target rect, so an empty rect = zero pixels painted.
Fix: Replace negative-height rect with painter pre-flip: painter.translate(0, H_px); painter.scale(1.0, -1.0) then call scene.render() with a normal positive rect. H_px must be the image height in pixels.
Lesson: Always test isEmpty() on any QRectF used as a render target. Negative-dimension rects are valid geometry in some contexts but empty in Qt's rendering pipeline.
Case study: PDF overview rendered as narrow left-edge strip (fixed 2026-05-01)
Symptom: PDF export page 2 showed the scene image as a thin strip at the left edge, not filling the content area. Despite correct code for the painter pre-flip, position was wrong.
Theories entertained (wrong):
- Wrong content_rect coordinates
- Painter viewport not matching page layout
- scale() applied before translate()
What instrumentation revealed: Added print(f"[PDF] initial painter.transform(): {p.worldTransform()}") before the pre-flip. Output showed a non-identity initial transform (QPdfWriter applies margin offsets before the painter is returned). The formula translate(0, cr.top + cr.bottom) assumed an identity baseline — invalid for QPdfWriter.
Root cause: QPdfWriter's painter has a non-identity initial transform from margin handling. The pre-flip baseline is shifted, so translate(0, top+bottom) overshoots.
Fix: Switch to "render scene to temp QImage (which has reliable identity transform), then embed with painter.drawImage(content_rect, img)". Immune to QPdfWriter's initial transform. See _scene_to_image() in pdf_report_service.py.
Lesson: Never assume QPainter starts at identity when targeting non-QImage devices (PDF, printer, SVG). Always read painter.worldTransform() first.
Case study: SVG texture fills inverted/brownish under Y-flip (fixed 2026-05-02)
Symptom: SVG export showed correct shapes and satellite image but a brownish overlay covering the scene. Texture-filled polygons (roof tiles, gravel) appeared wrong. PNG export was correct.
Theories entertained (wrong):
- Satellite image color space issue
- Some polygon covering full canvas with wrong fill
- Pattern tiling origin offset
What instrumentation revealed: Extracted pattern tiles from the SVG with a Python script (scripts/validate_exports.py + base64 decode). Tile images themselves were correct (e.g. grass texture shows green). Inspected SVG transforms: main group had matrix(0.213774, 0, 0, -0.213774, 0, 877) (scale + Y-flip). Pattern elements had no patternTransform. Rendered SVG to PNG via QSvgRenderer — confirmed brownish overlay visible.
Root cause: Qt's QSvgGenerator records <pattern> elements with patternUnits="userSpaceOnUse". The pattern tile images are stored in their natural (non-flipped) orientation. When the scene Y-flip transform is active, each tile renders upside-down within the Y-flipped coordinate space — a texture tile that looks like roof tiles right-side-up looks like abstract brown when flipped.
Fix: Post-process the SVG after painter.end(): read the file, find all <pattern> elements, add patternTransform="matrix(1,0,0,-1,0,{height})" to flip the tile back. See ExportService._fix_svg_pattern_yflip().
Lesson: Qt's SVG generator does NOT propagate painter transforms into pattern tile images. Any painter-level Y-flip requires explicit patternTransform compensation as a post-processing step.
Case study: SVG brownish overlay across satellite background (fixed 2026-05-02)
Symptom: After the patternTransform Y-flip fix, SVG export still showed a brownish-orange wash across most of the canvas, hiding the satellite background. PNG export was correct. A "transparent test" (forcing every opacity="0.x" to 0) made the satellite reappear — proving garden items were the culprit, not the satellite layer or canvas color.
Theories entertained (wrong):
- Satellite Z-order wrong (it isn't —
BackgroundImageItem.setZValue(-1000))
- Canvas background color leaking through (
#f5f5dc beige is fully opaque, never the brownish observed)
- Pattern tile origin offset
- Opacity stacking on transparent group hierarchy
What instrumentation revealed: A small Python script decoded every base64 pattern tile and inspected each <rect> in the SVG. Output:
<rect x="1035.83" y="393.78" width="4382.73" height="4382.73"/> ← roof tile
<rect x="3366.42" y="2800.94" width="1408.86" height="1408.86"/> ← roof tile
clipPath elements: 0 ← Qt did NOT serialize the painter clip region
clip-path attributes: 0
The texture rects were the painter's clip bounding rect, not the polygon shape. The actual polygon was serialized in the preceding "shadow" group: <g fill="#000000" transform="..."><path d="M2729...Z"/></g> followed immediately by <g fill="url(#texpattern_X)" transform="..."><rect x="..." y="..." .../></g>. Qt clips the rect against the painter clip region during native rendering, but the SVG contains no <clipPath> for the viewer to honor. So the rect bleeds across the entire canvas.
Root cause: QSvgGenerator does not emit <clipPath> elements for QPainter::setClipRegion/setClipPath calls. Texture-filled QGraphicsItems end up as a giant unconstrained rect in the SVG.
Fix: Post-process the SVG (ExportService._fix_svg_qt_texture_clipping) — pair each non-empty shadow group with the next non-empty texture group in document order, build a <clipPath> from the shadow's path (preserving its transform), and wrap the texture group with clip-path="url(#...)". Pairing must be 1:1 in document order with a used_textures set; a naive "scan 4000 chars ahead" matched the same texture from multiple shadows and produced overlapping replacements that corrupted the XML tree (mismatched </g> tags). Visual validation: render SVG via Edge headless (scripts/svg_preview.py) — Qt's QSvgRenderer is too forgiving and hides this class of bug.
Lesson: Qt's QSvgGenerator is not a faithful serializer of painter state. Anything beyond shape + fill + stroke (clip regions, composition modes, painter transforms applied to brush textures) must be recovered in post-processing. When pairing emitted constructs (shadow ↔ texture), walk both lists in lockstep with a used set — never use a forward window scan, because Qt emits empty bookkeeping groups that throw off positional heuristics. Always validate SVG output in a real browser, not just QSvgRenderer.
Case study: US-12.10d plant-soil mismatch border never appears (fixed 2026-05-03)
Symptom: Tomato in a bed with mismatched soil pH/N/P/K never triggered the amber/red bed border. SoilService.get_mismatched_plants() had a perfect implementation and 14 passing integration tests, yet the live app behaviour was silently broken. Manual hover tooltip showed one warning ("heavy N feeder") that never changed regardless of which soil parameters the user altered.
Theories entertained (wrong):
- The 500 ms debounce timer wasn't firing — but the same timer correctly drove the rotation handle hide/show and badge updates.
- The
_child_item_ids link from bed to plant was missing — verified, it was set correctly.
is_bed_type rejecting the rectangle — false, the bed had ObjectType.GARDEN_BED.
- The pH rule had a bug in its boundary comparison — re-read it five times, the logic was right.
- The plant-data file (
planting_calendar.json) lacked n_demand — true but not load-bearing; the legacy nutrient_demand="heavy" mapping covers it via _effective_demand().
What instrumentation revealed: A diff between PlantSpeciesData dataclass field list and the keys returned by to_dict():
fields: ..., nutrient_demand, n_demand, p_demand, k_demand, raw_data
to_dict: ..., nutrient_demand, raw_data ← three missing
from_dict: ..., nutrient_demand=..., raw_data=...
Three brand-new fields were declared on the dataclass (US-12.10d) but never added to either serialization site. So the live data flow library → plant_database_panel.set_plant_data() → metadata["plant_species"] = data.to_dict() → ... → PlantSpeciesData.from_dict(metadata["plant_species"]) silently dropped every per-nutrient demand value, leaving n_demand=p_demand=k_demand=None on the reconstructed spec. The N rule still fired via the nutrient_demand="heavy" legacy fallback in _effective_demand, but it now used the fallback mapping, not the direct field — and any test that set the direct fields would silently no-op.
Root cause: src/open_garden_planner/models/plant_data.py:165–221 and src/open_garden_planner/models/plant_data.py:223–291 — to_dict() and from_dict() were not updated when n_demand/p_demand/k_demand were added to the dataclass.
Fix: Add the three keys to both serialization sites. Add a regression test tests/unit/test_plant_data_serialization.py that iterates over every dataclasses.fields(PlantSpeciesData) and asserts presence in to_dict() output, plus a full equality round-trip.
Lesson: When adding a field to a dataclass that already has to_dict/from_dict methods, immediately grep for the dataclass name in the same file and update both serialization sites — and write a dataclasses.fields()-driven round-trip test. The integration tests passed because they constructed PlantSpeciesData instances directly and never round-tripped through dict; the bug only surfaced on the canvas → metadata → canvas data path. Construct-and-test is not the same as serialize-and-test. Whenever a dataclass has both code paths, both must be exercised.
Case study: data fields exist on the model but no UI to set them (US-12.10d, fixed 2026-05-03)
Symptom: Even after F1 fixed the silent serialization gap (case study above), tomato beds still didn't show pH-mismatch warnings in real use. Manual REPL round-trip of PlantSpeciesData(n_demand="high", ph_min=5.8) worked perfectly — the data plumbing was correct. But in the running app, every plant the user dropped had ph_min=ph_max=n_demand=p_demand=k_demand=None.
Theories entertained (wrong):
- The fix didn't actually deploy (it had —
git show df9871e:plant_data.py confirmed).
merge_calendar_data() was overwriting the new fields (it wasn't — it only merges calendar fields).
- The library lookup was returning a stale cached
PlantSpeciesData (no cache layer exists).
Key signal from the user: a screenshot of the plant details panel showing no row for pH or NPK demand. The fields existed on the dataclass and round-tripped through dict, but the UI never showed them. So the user had no way to set them — every plant arrived with None because the bundled data files (`planting_calendar.json`) only carry `nutrient_demand: "heavy"` and the API doesn't return pH ranges, leaving the new fields permanently empty.
Root cause: src/open_garden_planner/ui/panels/plant_database_panel.py — _create_editable_fields() had no rows for ph_min, ph_max, n_demand, p_demand, k_demand, or nutrient_demand. The model exposed the fields; the panel didn't.
Fix: Added 5 new form rows (pH range Min/Max, N/P/K demand combos, overall demand combo) between Hardiness and Planted, with read-back in _on_field_changed and population in _show_plant_data. After any field change the panel calls view.refresh_soil_mismatches() so the bed border updates live.
Lesson: A serialization round-trip test proves data flows, not user intent flows. When you add a field to a model, also audit the panel/dialog/forms that read & write that model — a "ghost field" with no UI is worse than no field at all because it gives the appearance of completeness in the data layer while silently making the feature unusable. Concretely: when adding a field to PlantSpeciesData, also grep plant_database_panel.py for any nearby field of the same model (e.g. hardiness_zone_min) — that's the natural place to add the matching UI row.
Sister issues raised (deferred to follow-up work, but caught during this debug session):
- #170 — autoloading from a shipped local species DB on canvas drop (so the new fields actually have values).
- #171 — past records in the History tab need edit/delete affordances; a typo currently requires deleting the whole bed.
Case study: QGraphicsPolygonItem.shape() is the stroke envelope, not the outline (US-12.10/F2.6a, fixed 2026-05-03)
Symptom: After fixing the soil-mismatch border to call closeSubpath() on self.shape(), all polygon edges were finally painted — but the closing edge was visibly thinner than the others.
Wrong theories:
- Anti-aliasing artifact at the closing vertex (no — clearly a different stroke width).
closeSubpath() not being applied (verified it ran).
- Pen join style needed
MiterJoin (didn't fix it).
Key signal: visually, the closing segment looked like a single hairline, while the other edges were a clean 4 px stroke. That's the signature of stroking a thin-band shape: the outline gets a 4 px stroke but the band itself is < 4 px wide.
Root cause: Qt's QGraphicsPolygonItem.shape() does not return the polygon's outline. It returns the stroke envelope — a closed band path that's the polygon outline expanded by the pen width, intended for hit-testing (so clicking near the edge counts as a hit). Stroking that band's outline produces the observed double-line effect, with the addPolygon-induced open seam reduced to a thin closing line.
Fix: When the item has a polygon() method (i.e. it is a QGraphicsPolygonItem), bypass shape() entirely and use painter.drawPolygon(self.polygon()). That uses the raw vertex list and produces a uniform stroke on every edge with proper miter joins. Rect / circle / ellipse keep the drawPath(self.shape()) fallback because their shape() does return a closed outline.
Lesson: QGraphicsItem.shape() is hit-testing geometry, not drawing geometry. When you need to outline an item, use the item's primitive (polygon, rect, ellipse) not its shape. Reach for painter.drawPolygon/drawRect/drawEllipse over drawPath(self.shape()) whenever you can.
Case study: early return inside a paint() branch silently bypasses later draws (US-12.10/F2.6b, fixed 2026-05-03)
Symptom: GARDEN_BED rectangles correctly showed soil-mismatch borders. RAISED_BED rectangles never did. Both pass is_bed_type(), both have a _soil_mismatch_level, both call the same paint hook.
Wrong theories:
is_bed_type(RAISED_BED) returning False (verified true).
- Pixmap rendering covering the border (no — pen has alpha 220).
- Selection-handle code stealing focus (irrelevant to paint).
Key signal: instrumenting paint() showed the border code at line 317 never ran for raised beds. That code is unconditional within is_bed_type — so something earlier was returning.
Root cause: rectangle_item.py:290 — RAISED_BED is rendered as a furniture pixmap (the wooden-frame look), and that branch had an early return (line 290) at the end of the pixmap block. Every line below that — grid overlay, rotation indicator, and the soil mismatch border — was bypassed for raised beds. The original code reviewer of US-12.10d wired the border at line 317 thinking it was reachable for all bed types.
Fix: Add a second _draw_soil_mismatch_border call inside the early-return branch, just before the return. Both the pixmap path and the standard path now paint the border.
Lesson: When wiring a new draw call into an existing paint() method, search the method for every return statement and confirm each control-flow path reaches your new code. Better: factor reusable post-paint hooks into a method called at every exit point. An early-return inside an if block is a classic stale-call site for new features added later.
Case study: outer dialog OK appends a duplicate after sub-dialog edit (US-12.10/F2.6c, fixed 2026-05-03)
Symptom: Editing a past soil-test record via the History tab → sub-dialog accepted, history list updated. But after closing and reopening the bed's soil dialog, there were now two records: the edited original and a duplicate of the pre-edit values.
Wrong theories:
EditSoilTestCommand was appending instead of replacing (verified by direct unit test — it correctly mutated by id).
- Race condition in the canvas refresh callback (no — the duplicate was on disk).
- The user pressed OK on the sub-dialog twice (single press confirmed).
Key signal: the outer dialog's status bar showed "Soil test recorded" after the user closed the dialog with OK. They thought OK = "save my changes", but the outer dialog's result_record() had already been built from the entry tab, which was populated at construction time with the pre-edit existing_latest. So AddSoilTestCommand appended a stale copy.
Root cause: application.py:_open_soil_test_dialog unconditionally fired AddSoilTestCommand on every accepted dialog, regardless of whether the entry tab actually changed.
Fix: Compare result_record() to the original existing field-by-field (ignoring id and date); if equal, status-bar "No changes" and skip the command. The user's OK becomes a no-op when they only used History-tab affordances.
Lesson: Modal dialogs that mix "view past data + edit current data" hide a state-capture trap: any sub-dialog that mutates the underlying state leaves the outer dialog showing stale form values. Either keep state-mutating actions out of the outer dialog (separate browser/editor flows) or always compare-before-commit on accept. Don't trust the user's OK to mean "I want to save the entry tab" if the entry tab was never touched.
Case study: same-zValue items reverse stacking after .ogp save/load (US-12.10/F2.7, fixed 2026-05-03)
Symptom: A tomato dropped on a polygon bed rendered correctly during the live session. After saving the project and reopening it, the bed was on top — the tomato was gone (actually still in the scene, just hidden behind the bed).
Wrong theories:
- The plant wasn't being saved (
scene.items() after load showed it present).
- The plant's transform was wrong (correct — the dot was just hidden).
- A z-value field wasn't being persisted in
.ogp (it isn't, but that's a symptom not the cause).
Key signal: in the live session, both bed and plant had zValue() == 0. The plant was on top. After load, both still had zValue() == 0 — but the bed was on top. So the tie-break between same-z items had flipped between sessions.
Root cause: canvas_scene.py:_update_items_z_order sets every item's z to layer.z_order * 100. Items in the same layer get the same z. Qt's QGraphicsScene then tie-breaks by item insertion order. The live session inserts bed first, then plant — plant on top. The post-load reconstruction inserts items in scene-traversal order from the saved JSON, which is reversed by serialization, putting the plant first and the bed on top.
Fix: Add a third pass in _update_items_z_order (mirroring the existing ROOF_RIDGE special case at line 658) that walks every item with _parent_bed_id set and bumps its z to parent.zValue() + 1. Now plants always have a strictly higher z than their bed, regardless of insertion order.
Lesson: Identical zValues are a footgun across save/load boundaries because QGraphicsScene tie-breaks by insertion order, which is not stable between live mutation order and JSON-load order. Whenever a parent-child draw relationship matters, encode it explicitly via parent.zValue() + 1 — never rely on "I inserted them in the right order, it'll just work". Pattern: anywhere _update_items_z_order touches multiple item categories, add an explicit ordering pass per parent-child relationship.
Case study: model has display_name(lang) but call sites use .name (US-12.10/F4, fixed 2026-05-03)
Symptom: With German locale active, the soil-test dialog's amendments list and the Amendment Plan table both showed substance names in English ("Dolomite lime", "Blood meal") despite the bundled amendments.json carrying perfect German name_de translations and the Amendment dataclass having a display_name(lang) helper.
Root cause: format_amendment_line and AmendmentPlanDialog._populate_table both read rec.amendment.name directly — bypassing the localisation helper.
Lesson: When you add a localisation helper to a model (display_name(lang)), grep every read of the underlying field (.name) in the same package and switch them over. A helper added without consumers is dead code that gives a false impression of i18n coverage. Same family of bug as F2 ("ghost field") but at the call site instead of the UI layer.
Case study: clipboard format that LOOKS right but fails on paste (US-12.10/F10, fixed 2026-05-03)
Symptom: AmendmentPlanDialog → "Copy to clipboard" → paste into LibreOffice / Excel → everything dumped into a single column.
Root cause: _build_clipboard_text produced human-readable bullet lines (- Dolomite lime: 10.4 kg (Bed A, Bed B)). Visually fine on a notepad, but the spreadsheet has no separator to split on.
Lesson: "Copy to clipboard" buttons targeting spreadsheets must produce tab-separated rows with a header row. Always test the receiving application, not just the rendered string. Add a regression test that asserts exact column count via line.count("\t") == n.
Case study: max() ties hide newer records of the same date (US-12.10/F2.10a, fixed 2026-05-04)
Symptom: User saves a Lab-mode soil test on a bed that already has a Kit-mode record dated the same day. Reopens the dialog → defaults to Kit. The History tab seems to show only one record. The .ogp file does contain a record with mode: "lab", but the dialog can't see it.
Wrong theories:
AddSoilTestCommand silently dropped the record (verified — it appended).
to_dict wasn't emitting the mode field (verified — it did when != "kit").
_records_equivalent dedup'd it out (mode differs → guard passed).
- Q-signal ordering issue inside the dialog rebuild after save.
Key signal: side-by-side comparison of the .ogp file (which had the lab record) and the dialog state on reopen (existing_latest.mode == "kit"). The lab record was on disk but latest returned the kit record.
Root cause: models/soil_test.py:113 — SoilTestHistory.latest was implemented as max(self.records, key=lambda r: r.date). Python's max() returns the first maximal element when keys tie ("If multiple items are maximal, the function returns the first one encountered"). The Kit record was appended first, so it won every same-day tie. Compounded by _format_history_row showing only categorical fields — the user couldn't tell two records existed for that date.
Fix: Walk reversed(self.records) and return the first match for the max date. Plus add a [Lab] / [Labor] suffix to History-tab rows whose mode == "lab" so they're visually distinguishable from Kit rows on the same date.
Lesson: max(iterable, key=...) is left-biased on ties. For a "most recently saved record" that uses date as the key, the first save with the max date wins — not the last. Whenever the semantic is "newest among items with equal sort keys", either (a) walk the iterable backwards, (b) use a tuple key including a stable secondary sort (insertion index, uuid, monotonic counter), or (c) use sorted(...)[-1]. Bonus heuristic: if a sort/aggregation key has limited resolution (a date, not a datetime), assume ties are common and design the tie-break explicitly.
Notes from the same sweep (no separate case study warranted)
-
F2.10b — bed history merge with global default: a UX-semantics fix. The default test should be the bed's fallback, not a permanent overlay. Once a bed is tested, the default vanishes from its history; delete the last bed record and the default reappears. Lesson worth remembering: when implementing a "fallback" relationship, the UI should show the fallback only when actually applied — having it always visible obscures whether the bed has its own data.
-
F2.10c — RAISED_BED on circles/ellipses: pixmap-based rendering doesn't clip to the underlying shape. A round bed with RAISED_BED rendered as a square wooden frame. Lesson: when a type carries a fixed-aspect-ratio raster asset (the wooden-frame pixmap), the "valid shapes" list for that type must match the asset's aspect — otherwise the result is incoherent. Drop the option from incompatible shape lists rather than trying to clip the pixmap (which would distort it).
Case study: soil-mismatch warning goes stale on plant move/reparent (issue #173, fixed 2026-05-07)
Symptom: User drops a tomato (auto-populated with ph_min=6.0 after #170) into a bed with pH=4.0. Bed edges turn red ✓. Drags the tomato outside → edges stay red. Bumps bed pH 4.0 → 4.1 → edges flip green. Drags the tomato back into the bed → edges stay green. Bumps pH 4.1 → 4.2 → red again. The recompute logic is correct; what's broken is the trigger.
Wrong theories:
_update_soil_mismatches had a bug (verified — synchronous calls from soil-test save worked perfectly).
_child_item_ids wasn't being updated by SetParentBedCommand (verified — it was, immediately).
- The 500 ms debounce timer wasn't firing (the most plausible-sounding theory, and partly true — see root cause).
Key signal: tracing _update_soil_mismatches showed it ran on every soil-test save and every position change during the drag, but never after the parent-link mutation that completes the drop. Cross-referenced with Qt docs: QGraphicsScene.changed is described as "emitted when the scene changes", which everyone reads as "any state change". It is not — it's "any visual change". Python attribute writes don't trigger it.
Root cause: src/open_garden_planner/ui/canvas/canvas_view.py:651-654 — the debounce that drives soil-mismatch refresh is wired exclusively to scene.changed. After a drag, _update_plant_bed_relationships calls SetParentBedCommand which mutates parent_bed_id and _child_item_ids — plain attribute writes that emit no Qt signal and trigger no scene-rect invalidation. The 500 ms timer never restarts for the parent-link change. The next genuine scene change (e.g. the user editing pH) is what finally refreshes — explaining why steps 4 and 6 of the repro work and steps 3 and 5 don't.
Fix: Add trigger_soil_mismatch_refresh(scene) (commands.py) that walks scene.views() and calls refresh_soil_mismatches() on the canvas. Call it from SetParentBedCommand.execute/undo so every attach/detach call site (drag, properties-panel "Unlink", future) stays in sync. Bonus catch in the same fix: SetParentBedCommand also wasn't elevating the plant's z above the bed's, so a plant drawn before its bed rendered behind it after attach — same class of bug (mutation without re-establishing the invariants the rest of the canvas assumes). Both invariants — z elevation and soil-mismatch refresh — now run inside the command, with the elevation rolled back symmetrically on undo via a _pre_execute_z snapshot.
Lesson: When a debounced/event-driven refresh handler exists, it imposes an implicit contract on every callsite: "if you change state I depend on, you must also produce the event I'm listening to." Python attribute writes never satisfy that contract. Two durable mitigations: (a) funnel state changes through Commands and put the refresh trigger inside the Command rather than at every caller; (b) when adding a new debounced handler, write down the contract in the docstring so the next person extending the code paths knows it exists. Bonus rule: any time you find a fix that's "do X also at site Y", grep for every callsite of the same operation — there are almost always 3-5 more.
Case study: tangent constraint flips to the opposite side of the circle on drag (PR "make snap-constraints real", fixed 2026-06-07)
Symptom: Draw a line tangent-snapped to a circle, then drag the circle. v1: the line stalls into a radial line through the centre ("stable but wrong"). After fix-1 (signed residual) v2: holds for small drags but a large drag flips the contact to the opposite side. After fix-2 (continuity warm-start) v3: connectivity holds but tangency drifts off (line slides to radial, constraint red). After fix-3 (drop POINT_ON_CIRCLE, pure tangent) v4: tangent holds but the contact is no longer welded to the rim (it slides along the line) — the user needs both.
Wrong theories:
- Sign of the emitted signed-radius is inverted (most plausible). Disproved by the user's own
[TANGENT] emit log: sign=+1, contact on side R, signed_dist=+320=target at creation — sign was correct.
- The creation-time
apply_constraint_solver() (both items free) flips it. Disproved — headless creation solve is a no-op (residuals 0 at the snapped point).
- Coordinate-space mismatch between
snap.point/mapToScene and the solver's get_anchor_points. Disproved — both are scene coords via the same mapToScene.
Key signal: instrument both the emit (one-shot) and _propagate_constraints_during_drag (per-frame) with [TANGENT] prints, then have the user run from a terminal (the full GardenPlannerApp hangs in the agent sandbox but runs fine for the user). The per-frame log showed signed_dist sliding +320 → 0 → −320 and settling at −target with side flipping R→L — a clean trajectory through the centre, not a one-shot sign error. Reproduced headless ONLY after matching the user's drag magnitude and direction (earlier small/wrong-direction repros passed). The decisive repro drove _propagate_constraints_during_drag frame-by-frame with the user's exact geometry from the log.
Root cause: the welded-tangent the user wants is POINT_ON_CIRCLE (contact on rim) + tangency. The trap was how tangency was expressed. Expressed as "signed perpendicular distance centre→line = ±radius", its gradient is the line-normal, which at a tangent config is parallel to POINT_ON_CIRCLE's radial gradient → rank-deficient Jacobian → the pair is degenerate and the solver drifts/stalls/flips (v1–v3 were all faces of this one ill-conditioning, compounded by an unsigned-|cross| kink and a stale from-original warm-start). Dropping POINT_ON_CIRCLE (v4) removed the degeneracy but lost the weld. The fix is to express tangency by a residual whose gradient is orthogonal to the radial one.
Fix: redefine ConstraintType.TANGENT as "the edge is perpendicular to the radius at the contact" — residual (C−v1)·(v0−v1)/|edge| (radius projected onto the edge → 0), gradient along the edge. Emit it with POINT_ON_CIRCLE: the radial gradient (POINT_ON_CIRCLE) and the edge-aligned gradient (TANGENT) are orthogonal → full-rank, non-degenerate. The contact is welded to the rim AND stays tangent, and co-moves with the circle. Enforce both passes (Gauss-Seidel translates along the edge by the residual — closed-form; Newton residual as backup) and keep the continuity warm-start (the contact-on-rim still has 2 antipodal solutions; continuity picks the near one). Files: core/auto_constraint.py, core/constraints.py, core/constraint_solver_newton.py, ui/canvas/canvas_view.py. See ADR-024.
Lesson: (a) When pairing constraints, the residual formulation decides conditioning, not just the geometry you mean. "Tangent" can be written as "distance-to-line = r" (gradient ∥ radial → degenerate with POINT_ON_CIRCLE) or as "edge ⟂ radius" (gradient ⟂ radial → well-conditioned). Same geometry, opposite numerical behaviour. Before concluding "this pair is impossible," try re-expressing one residual so its gradient is orthogonal to the other's at the solution. (b) Two constraints whose gradients are parallel at the solution are rank-deficient — the solver drifts no matter how good the warm-start. (c) A constraint with multiple solutions (contact-on-rim = 2 antipodes) needs continuous warm-starting; any driver that re-solves "from scratch" each frame breaks it while single-solution constraints keep working, hiding the bug until you add a multi-solution one. (d) When a GUI bug won't reproduce headless, get the exact user coordinates from instrumentation and drive the exact event path (live _propagate_constraints_during_drag, not _compute_constraint_propagation) — the [TANGENT] log pinned magnitude + direction and turned a non-reproducing test red.
Case study: new curve edit-handles appear but are completely inert / can't drag (issue #193, fixed 2026-06-08)
Symptom: New CurveControlHandle widgets render on a selected Bezier/Arc (blue/green squares show), but no handle — and seemingly nothing — can be dragged; the curve feels "stuck in place." All the unit/integration tests that called the item hooks (_move_control etc.) directly were green, so the geometry/undo logic was provably fine.
Wrong theories:
- The reshape math or undo snapshot is wrong. Disproved — the model hooks pass every direct test; the geometry mutates correctly when
_move_control is invoked.
- The handle's own
mousePressEvent/grabMouse is broken. Disproved — the handle mirrors VertexHandle exactly (same grabMouse() + ItemIgnoresTransformations + zValue).
- The draw tool is still active and eats the clicks. Disproved —
add_item/bezier_tool don't auto-select, so handles only appear once the user has selected with the SELECT tool, which lets item clicks through (select_tool.mouse_press returns False).
Key signal: tried to write a view-level reproduction. A hand-built QMouseEvent passed to view.mousePressEvent did not deliver to the handle (scene.mouseGrabberItem() stayed None, event unaccepted) even though itemAt found it — because Qt won't hit-test/deliver a synthetic press to an ItemIgnoresTransformations child (this is also why the polyline tests never drive view-level events). Switching to QTest.mousePress on view.viewport() (real event dispatch) finally grabbed the handle — and exposed that view._active_drag_handle was None after the press.
Root cause: CanvasView works around a PyQt6 bug where Qt silently drops the mouse grab on ItemIgnoresTransformations child items between events by tracking self._active_drag_handle on press and re-establishing the grab in mouseMoveEvent/mouseReleaseEvent. That tracking only fires for an allow-list of handle types (isinstance(grabber, (ResizeHandle, RotationHandle, VertexHandle, RectCornerHandle, MidpointHandle))). The new CurveControlHandle wasn't in the tuple, so the press grabbed but the grab was dropped before the first move and never re-established → the handle got the press and no moves → inert.
Fix: add CurveControlHandle to the allow-list tuple (and its import) in ui/canvas/canvas_view.py. One-line behavioural change. Regression test TestHandleDragViaView drives the real path with QTest and asserts view._active_drag_handle is handle after the press (fails without the fix, passes with it).
Lesson: (a) Any new in-scene handle that uses ItemIgnoresTransformations must be registered in CanvasView's _active_drag_handle allow-list — the dropped-grab workaround is opt-in by type, so a faithful copy of VertexHandle is still dead until the view knows about it. Grep _active_drag_handle when adding a handle class. (b) Tests that call item hooks directly can't see a view-routing bug; the riskiest layer (press→grab→move delivery) needs a real event-path test. (c) QGraphicsView does not deliver a hand-constructed QMouseEvent to ItemIgnoresTransformations children — use QTest.mousePress/Move/Release on view.viewport() (and centerOn the target first so it's inside the viewport) for faithful handle-drag tests.
Case study: rotated circle drag-resize collapses / drifts / ghosts (issue #218 follow-up, fixed 2026-06-17)
Symptom (PR #221 manual test, screenshots): drag-resizing a 45°-rotated plant was incoherent — a diagonal corner drag barely changed the diameter or collapsed it to ~minimum, the centre drifted across the canvas, the dragged handle did not follow the cursor, and a translucent "ghost" disc lingered where the spacing ring had been.
Wrong theories:
- The #218
_reanchor_after_rotated_resize re-pin is wrong. Partly — but the band-aid was correct for what it did (it held the serialization invariant; the headless trace showed serialized == visualCenter throughout). The rot was underneath it.
- The spacing ring should scale with the footprint. No — that decoupling is the intended #218 model (confirmed with the user); not the bug.
- Missing
prepareGeometryChange is the whole bug. No — that only explained the ghost disc, not the collapse/drift.
Key signal: a scripted headless reproduction (place plant → _apply_rotation(45) → feed a cumulative-delta drag through the real ResizeHandle._apply_resize, printing rect/radius/pos/origin/visualCenter each step) showed a BOTTOM_RIGHT drag of (80,80) leaving radius stuck at 50.00 — zero growth — and a TOP_LEFT outward drag shrinking the circle while the supposedly-fixed corner moved 45 cm. At exactly 45° a screen-diagonal drag projects entirely onto one local axis (local_dy ≈ 0), so min(width, height) picked the unchanged axis.
Root cause: three compounding faults in the interactive resize of a rotated circle. (1) CircleItem._apply_resize squared via min(width, height) — incoherent once width ≠ height under rotation (and it capped MIDDLE-handle growth entirely, since new_height == init_height). (2) Two disagreeing notions of "what stays fixed": CircleItem inferred the fixed edge from scene-space abs(pos_x − init_pos.x()) < 0.01, while the re-anchor inferred it from the rotated local pos_dx == 0 — under rotation they disagree, so the re-anchor pinned the wrong corner → drift. (3) Neither the per-item resize nor the shared helper called prepareGeometryChange(), so the shrinking boundingRect() (which includes the spacing-ring expansion) left stale pixels → ghost.
Fix: stop post-correcting an incoherent step — replace it. The interactive ResizeHandle._apply_resize now takes the fixed corner/edge authoritatively from self._position, lets the item normalise the rect (CircleItem._constrain_resize_size squares it so the dragged handle tracks the cursor — corner → max(w,h), edge → that axis, so a side handle can now grow a circle), applies it through resize_rect_item_keeping_anchor (now prepareGeometryChange() + origin re-pin), and refreshes via _after_resize_geometry(). The rotation-gated _reanchor and the min(w,h) + scene-space guess are deleted. Pinned by tests/integration/test_rotation_aware_resize.py ({Circle,Rect,Ellipse}×{0,45,215°}×{corner,edge}). See ADR-028 + §11.4.
Lesson: (a) Don't post-correct an incoherent geometry step — fix the step. A re-anchor layered over min(w,h) + a dual fixed-corner inference can never be right because the layer beneath produces nonsense; the senior review flagged this exact fragility before it shipped. (b) When a gesture has a "fixed reference", derive it from the one authoritative source (the handle position), never re-infer it in two places in two coordinate frames — they will disagree under rotation. (c) A scripted headless drive of the real event-handler (ResizeHandle._apply_resize with cumulative deltas) printing geometry each step nails magnitude+direction bugs that a GUI can only show vaguely — and at exactly 45° watch for axis-projection degeneracies (local_dy ≈ 0) that min()/max() turn pathological. (d) Any shrink of a custom boundingRect() needs prepareGeometryChange() or Qt leaves a ghost.
Case study: export_dxf works in dev venv but errors "No module named 'unittest'" only in the frozen exe (US-D1.4, fixed 2026-07-04)
Symptom: the new Agent API export_dxf MCP tool worked perfectly under pytest and a plain venv script, but calling it against the packaged .exe returned an MCP tool error: "Error executing tool export_dxf: No module named 'unittest'". The other three new D1.4 tools (save_plan, export_pdf, export_csv) and the pre-existing render_canvas_image all succeeded against the same running frozen exe — only the DXF path failed.
Wrong theories:
- Something about running inside
anyio.to_thread.run_sync + MainThreadBridge.run_on_main's worker thread breaks frozen imports. Disproved — render_canvas_image goes through the exact same async/thread-hop machinery and worked fine.
- A
sys.meta_path shim in a plain venv script simulating PyInstaller's excludes=["unittest"] seemed like a reasonable stand-in for reproducing the frozen behaviour without a full rebuild — it wasn't: blocking unittest via sys.meta_path in a normal interpreter and calling ezdxf.new()/doc.saveas() succeeded even with the block in place, which incorrectly suggested ezdxf itself doesn't need unittest at runtime at all. A pure-Python import-blocking trick does not faithfully reproduce a PyInstaller excludes list — the real bundle simply has no unittest bytecode anywhere, which is a stronger condition than "the next import unittest raises."
- It must be a bug specific to my new
agent_api/exports.py module — disproved once the traceback showed the failure was three frames inside ezdxf's own import graph, nothing to do with exports.py at all.
Key signal: the app is built windowed (console=False in installer/ogp.spec), so an exception caught by FastMCP's tool-error handling never surfaces a traceback anywhere visible — MCP just returns the stringified exception. Wrapping the one call site (DxfExportService.export(...) inside export_dxf_file) in a try/except that wrote traceback.format_exc() to a file, rebuilding, and re-triggering the call from a real MCP client produced the real chain:
export_dxf_file → dxf_service.py:100 (import ezdxf)
→ ezdxf/__init__.py → ezdxf/filemanagement.py → ezdxf/tools/standards.py
→ ezdxf/render/__init__.py → ezdxf/render/mleader.py → ezdxf/entities/__init__.py
→ ezdxf/entities/acad_proxy_entity.py → ezdxf/query.py → ezdxf/queryparser.py
→ pyparsing/__init__.py → pyparsing/testing.py
ModuleNotFoundError: No module named 'unittest'
Every one of those is a plain, unconditional module-level import — so this chain fires on the first-ever import ezdxf anywhere in the frozen process's lifetime, regardless of whether it's triggered by DXF export, DXF import, or the new agent tool.
Root cause: installer/ogp.spec's excludes list had "unittest" (presumably added purely to trim bundle size, with no comment explaining why). ezdxf's DXF-entity-query support (ezdxf.query, unconditionally imported by ezdxf.entities.acad_proxy_entity, unconditionally imported by ezdxf.entities, unconditionally imported by ezdxf.render, unconditionally imported by ezdxf.tools.standards, unconditionally imported by ezdxf.filemanagement, unconditionally imported by ezdxf/__init__.py — i.e. reachable from any import ezdxf) depends on pyparsing for its query-string grammar. pyparsing/__init__.py unconditionally imports its own pyparsing.testing submodule, which subclasses unittest.TestCase for a test-assertion mixin — a genuine (if surprising) runtime dependency on unittest, not merely a test-time one. This is a pre-existing latent packaging bug predating this PR — it would have broken the already-shipped GUI "Export as DXF"/"Import DXF" (US-12.3/12.4) too, the first time either was exercised in a freshly-built frozen exe. It simply hadn't been caught because manual DXF testing is normally done against the dev venv, and this PR's exe-verification step was the first thing in a while to exercise a DXF codepath in an actually-frozen build.
Fix: remove "unittest" from installer/ogp.spec's excludes list, with a comment explaining the pyparsing.testing chain (mirroring the existing # NOTE: do NOT exclude "multiprocessing" comment already in that list for an analogous uvicorn reason). Verified end-to-end: rebuilt the exe, called export_dxf via a real MCP client against the running frozen server — file now written successfully (FILE_EXISTS=True, valid DXF content) — and re-ran the full pytest/ruff/bandit gates to confirm the un-exclude introduced no regressions.
Lesson: (a) A packaged app's excludes list is a claim about the entire transitive dependency graph never needing a module at runtime — a claim that can be silently falsified by a sub-sub-dependency nobody audited (here, pyparsing, pulled in only because ezdxf happens to support DXF entity queries). Before excluding a stdlib module to save space, grep the actual dependency tree for it, or accept that the first unexercised codepath through a frozen build might discover the gap. (b) When a console=False (windowed) frozen app hides a real traceback behind a caught-exception error string, temporarily wrap the one suspect call in a try/except that writes traceback.format_exc() to a file, rebuild, reproduce, read the file, then remove the instrumentation — don't try to simulate "missing from a frozen bundle" with a sys.meta_path import-blocking trick in a normal interpreter; that only proves the module isn't cached, not that it's genuinely absent, and can produce a false negative that sends you down the wrong path. (c) When a new feature is the first to exercise a codepath (DXF, in this case) in a freshly rebuilt exe, a failure there may not be "new" at all — check whether any existing, already-shipped feature shares the same first-import trigger before assuming the bug is scoped to your change.
Case study: a new regression test failed with the fix applied — the harness was the variable (#283, fixed 2026-07-27)
Symptom: while fixing #283 (three QToolBars missing an objectName), a new integration test asserting that a hidden toolbar's state survives a saveState()/restoreState() round trip failed with the fix applied, at assert category.isHidden(). The identical sequence, run as a standalone script, passed. The test also failed when run alone, so it was not test-order interference between the file's own tests.
Wrong theories:
- "
restoreState() applies visibility lazily; the script's processEvents() is doing the work." Refuted by instrumenting both paths: visibility was applied immediately, before any event processing, in both.
- "The fix doesn't actually work in this scenario." Refuted by the same probe reporting the correct toolbar hidden.
Key signal: the standalone script and the pytest run differed in exactly one input nobody had listed as an input — the contents of the QSettings store at construction time. GardenPlannerApp.__init__ calls _restore_ui_state(), which restores a previously saved window state and thus a previously saved toolbar layout.
Root cause: app/ui_state.py's UiStateStore constructs QSettings("cofade", "Open Garden Planner") directly instead of going through app/settings.py, so tests/conftest.py::isolate_qsettings — which works by replacing AppSettings.__init__ — never covered it. Every full-app test was silently reading the developer's real saved window state (and rewriting it at teardown, because pytest-qt closes registered widgets and closeEvent persists UI state — measured at 120 real-store writes from a single test file). Earlier throwaway probes in the same session had left a layout with CategoryToolbar hidden in that real store, so the test's precondition was already violated before its first line ran.
Fix: first an autouse _isolate_ui_state fixture pointing ui_state.QSettings at the test key (local runs then also matched CI, where the store is always pristine). #285 / ADR-041 then did the real repair and deleted that fixture: app/settings.create_qsettings() is now the only place that constructs or even names a settings store, and tests/conftest.py rebinds settings.ORGANIZATION_NAME / APPLICATION_NAME — which the factory re-reads on every call — at its own import time, at module scope, not in a fixture. pytest imports the root conftest before any test module, so every store the app builds, including one built while a module is being imported, lands in the test key. A fixture could not do this: it runs after collection, and a QSettings binds its organization/application at construction. Enforced by tests/unit/test_settings_chokepoint.py (an AST walk over src/ and tests/: nobody else may name QSettings, and nobody should build a store at import time — such a store can be redirected by nothing afterwards; belt-and-braces behind the conftest redirection, not the guarantee itself) + tests/integration/test_settings_isolation.py (spies QSettings.value/setValue during a full app boot).
Lesson: when a test fails with a fix that a direct probe says works, suspect the harness before the fix — and enumerate the hidden inputs. Persistent state (QSettings, registry, <app-data> files) is an input to every test that constructs a window, whether or not the test mentions it. Corollary: an isolation fixture only covers the construction path it patches; a second store that hand-rolls its own QSettings gets a free pass and nobody notices until its state changes under a test. Also worth knowing before you try to "just look at the console": on Windows, Qt's default message handler writes to OutputDebugString rather than stderr when stderr is not a console, so piping the app's output through grep prints nothing whether or not the warning fires — qInstallMessageHandler is the only reliable programmatic instrument.
Corollary — the same trap bites the probe, and it bites the developer's real config (found in manual testing of the very same PR): a throwaway script that constructs GardenPlannerApp runs outside pytest, so tests/conftest.py isolates nothing. AppSettings and UiStateStore both resolve to the real QSettings("cofade", "Open Garden Planner"). The probes for #283 each opened with the line the test fixtures legitimately use — get_settings().show_welcome_on_startup = False (to keep the modal Welcome dialog from blocking) — and thereby persisted it into the user's own configuration. Symptom reported after the branch was pushed: "the window to pick and load old projects is missing, you land straight on an empty canvas." Nothing in the diff caused it; the debugging did. Any probe script that constructs the real app must redirect the store first — or never write a setting at all. Since #285 that is one line, not two: open_garden_planner.app.settings.ORGANIZATION_NAME = "cofade_probe" (plus APPLICATION_NAME) before building the window, or patch settings.create_qsettings to a temp INI as tests/unit/test_ui_state.py does. AppSettings and UiStateStore both take their backend from that factory, so there is no second store left to forget. Also: if the app writes state at teardown, do not call win.close() in a probe; just let the process exit. And when a probe is done, diff the real store (QSettings(...).allKeys()) against what you expected to touch, rather than assuming the script was read-only.