prism branch worktree-t1440-t1441-test-fixes commits 1 files 4 touched lines +68 / -8

Pre-push review: T-1440 / T-1441 test fixes

One commit fixing two macOS test-quick failures, plus the real exporter idempotency bug one of them surfaced.

At a glance

  • Real bug fixed: notes anchored to a list block gained a blank line on every export→import→export cycle (non-convergent growth). Root cause is swift-markdown's context-dependent list range; fix anchors the note separator at the block's last text char via a new textEndOffset() helper.
  • Test fidelity: the property suite's roundTrip() helper now reconstructs reimported notes the way production's ImportedNotesProcessor does (preserving id/timestamp), instead of fresh UUID()+Date() that made id=/ts= drift.
  • Realistic fixtures: generated sources now end with a trailing newline like real markdown files.
  • T-1441: a SpyDiagramWindow test double records minimize/restore state so the tests no longer depend on a live window server. No production code changed for this one.
  • Out of scope, flagged: a broader set of pre-existing notes/export failures on main (stale V2 id=/ts= format expectations) is tracked separately in T-1457.

Verdict

Ready to push

Both target suites are green on macOS (ExportImportRoundTripPropertyTests, DiagramWindowManagerTests) and lint is clean. The production exporter change was verified regression-free against a HEAD baseline across the whole exporter/notes test surface (identical failing set with and without the fix). The one BLOCKING review finding was investigated and proven a false positive with a targeted test. No code changes were required from the review.

Review findings

5 raised · 0 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism lets you attach notes to a markdown document and export them back out as > [!COMMENT] blocks. Two automated test groups were failing on macOS. This commit fixes them.

One failure turned out to be a real bug: if your note was attached to a bulleted/numbered list, every time you exported, re-opened, and re-exported the file, an extra blank line crept in before the comment — forever. The fix tells the exporter to place the note right after the list's text instead of after a stray newline.

The other failure was a test-only issue: tests that exercise window minimize/restore can't actually minimize windows on a headless build machine (there's no screen), so they now use a fake window that just remembers “I was asked to minimize.”

Why it matters

Without the fix, a document that round-trips through export/import would slowly accumulate blank lines. The tests now pass and guard against that regression.

Architecture

InlineNotesExporter.export() maps the source into blocks (ExportSourceMapper), then injects each note's \n\n> [!COMMENT]… blockquote at the anchor block's endOffset, applying all edits end-to-start so earlier offsets stay valid.

The bug

swift-markdown returns a context-dependent range for a list: its trailing newline is included when another block follows it (e.g. the injected comment) but excluded for the same list at end-of-document. So the note's \n\n separator anchored one newline too late on re-export, and the leftover newline accumulated each cycle — non-convergent.

The fix

A small textEndOffset(in:blockEnd:) helper walks back over trailing \n so top-level note and table-row injections anchor at the block's last text character. It is a no-op for paragraphs (whose range already excludes the trailing newline).

Test fidelity

The property test's reimport helper was rebuilding notes with a fresh UUID() (random noteHash) and Date(), so id=/ts= drifted every round-trip — a test artifact, not a product bug. It now mirrors ImportedNotesProcessor: preserve noteData.noteId as exportId/noteHash and noteData.timestamp as createdAt. Fixtures gained a trailing newline to match real files.

Root cause

The non-idempotency is information loss: the first export turns "text" into "text\n\n> [!COMMENT]…"; stripping the comment on re-import can only recover "text\n", not "text". For paragraphs swift-markdown's block range excludes the trailing newline so the separator re-anchors cleanly and the export is a fixed point from pass two onward; for lists the range includes the trailing newline when a block follows, so the \n\n compounds and grows by one newline per cycle.

Why trimming is safe w.r.t. comment tags

The .topLevel branch also emits inline <!-- comment:hash --> tag injections at blockStart + range.startOffset/endOffset, which were intentionally left untrimmed. Those offsets live in the block's text space (range.endOffset ≤ text length), and trimming only removes trailing newlines that sit after all text — hence after every tag offset. So the trimmed top-level injection is always ≥ the close-tag offset; no reorder can place the [!COMMENT] block between an open/close tag pair. Verified empirically with a list block carrying both a tagged (textRange) note and a block-level note: tags stayed well-formed and the export converged (first == second == third).

Scope

The fix covers .topLevel and table-row injections — the paths T-1440 exercises. The .listItem path (T-1144-tuned indent/tie-break logic) is left untouched; any list-item-anchored newline growth is tracked under the broader T-1457 alongside stale V2-format test expectations.

Important changes — detailed

InlineNotesExporter: anchor note injection at block's last text char

InlineNotesExporter.swift

Why it matters. Production fix. Removes unbounded blank-line growth for list-anchored notes across export/import cycles; user-visible document stability.

What to look at. InlineNotesExporter.swift: textEndOffset() helper + two injection sites (.topLevel, table-row)

Takeaway. When injecting at a swift-markdown node's range offset, normalise away trailing newlines — list ranges include the trailing newline only when a sibling block follows, paragraphs never do.
Rationale. Normalising all top-level docs to end in a newline would break the 'export with no notes returns source unchanged' invariant; trimming the injection offset is the minimal change and is a no-op for paragraphs.

Property-test roundTrip() helper now mirrors production reimport

ExportImportRoundTripPropertyTests.swift

Why it matters. The id=/ts= drift was a test artifact masking (and then unmasking) the real exporter bug; fixing it makes the assertion meaningful.

What to look at. ExportImportRoundTripPropertyTests.swift: reimport loop preserves noteId/timestamp/threadId/extraFields

Takeaway. Round-trip property tests must reconstruct state the way the production import path does — here ImportedNotesProcessor preserves the parsed id and timestamp; a fresh UUID()/Date() silently breaks idempotency.
Rationale. BlockNote.noteHash derives from the note's UUID unless an explicit hash is supplied, so a fresh UUID() yields a new id= each round-trip.

Newline-terminate generated fixtures

ExportImportRoundTripPropertyTests.swift

Why it matters. Real markdown files end with a newline; non-terminated fixtures exercised an EOF edge unrepresentative of real input.

What to look at. ExportImportRoundTripPropertyTests.swift: randomDocument() + unicodeInNotes literal append \n

Takeaway. Fixture realism is part of test correctness — a missing trailing newline can manufacture failures real inputs never hit.
Rationale. With a trailing newline the export is byte-stable from the first pass; without one it converges only from the second pass.

SpyDiagramWindow test double for minimize/restore

DiagramWindowManagerTests.swift

Why it matters. Removes a hard dependency on a live window server so the three minimize/restore tests pass headlessly.

What to look at. DiagramWindowManagerTests.swift: SpyDiagramWindow subclass + makeMockWindow()

Takeaway. For AppKit state that only a window server mutates (isMiniaturized), subclass and record the request rather than asserting on live state in unit tests.
Rationale. Adding minimize-state tracking to DiagramWindowManager purely for tests would be production state with no production reader; a test double keeps the production type clean.

Key decisions

Fix the exporter rather than mask the bug with fixtures.

The property test caught a genuine non-convergent idempotency bug. The fix targets the root cause (injection offset) and was verified regression-free against a HEAD baseline across the exporter/notes suites, rather than hiding the bug by steering the generator away from list-at-EOF.

Newline-terminate fixtures instead of weakening the assertion.

Keeping the strong byte-exact first == second assertion while making sources realistic (trailing newline) is preferable to relaxing the assertion to a fixed-point check, which would have hidden the EOF edge.

Scope to .topLevel + table-row; leave .listItem to T-1457.

T-1440 exercises top-level list-block notes. The .listItem path sits inside T-1144-tuned indent/tie-break logic with no failing-test coverage for the newline case, so extending the trim there was judged riskier than the benefit and is tracked under the broader T-1457.

Spy NSWindow over production minimize-state tracking.

A SpyDiagramWindow records the requested miniaturize state; adding such state to DiagramWindowManager would introduce production state with no production caller.

Review findings

SeverityAreaFindingResolution
majorInlineNotesExporter — trim vs inline comment-tag offsetsReviewer flagged that trimming the block-level note injection offset could reorder it before an inline comment tag's close offset on a list block, injecting the [!COMMENT] block between the open/close tags and corrupting markup.Verified FALSE POSITIVE. Tag offsets live in the block's text space (range.endOffset ≤ text length), so they are always ≤ the trimmed offset; trimming only removes trailing newlines after all text and tag offsets. Confirmed with a targeted test: a list block carrying both a tagged (textRange) note and a block-level note kept tags well-formed and converged (first == second == third). The pre-existing CommentTag* suites also show an identical pass/fail set with and without the fix.
minorInlineNotesExporter — .listItem not trimmed.listItem injection still uses the raw endOffset, so list-ITEM-anchored notes followed by content may retain the same newline growth.Intentionally scoped to T-1440 (top-level list-block notes). The .listItem path is T-1144-tuned and has no failing-test coverage for this case; tracked under the broader T-1457.
minorInlineNotesExporter — strip ranges untrimmedBlock-level note injections are trimmed but comment-block strip ranges still use raw offsets.By design: strip ranges expand to full lines via expandToFullLines(), which is more robust than character trimming. Reviewer agreed it is correct/harmless.
minorInlineNotesExporter — Array(cleanSource) allocationcleanChars is allocated unconditionally (even with no notes) and the source is arrayified a second time inside expandAndMergeStripRanges on re-export.Export is a user-triggered cold path; a single shared [Character] array is the efficient choice for multiple injections (a String-index backward walk would be O(offset) per call). Reviewers rated it minor / not-pressing. Left as-is per 'don't overcomplicate'.
minorCode reuseChecked whether textEndOffset and SpyDiagramWindow duplicate existing helpers.No existing equivalent in the codebase; both are genuinely new. No action.

Per-file diffs

Click to expand.

prism/Services/InlineNotesExporter.swift Modified +25 / -2
diff --git a/prism/Services/InlineNotesExporter.swift b/prism/Services/InlineNotesExporter.swiftindex 0bc1f35..a3fee38 100644--- a/prism/Services/InlineNotesExporter.swift+++ b/prism/Services/InlineNotesExporter.swift@@ -50,6 +50,7 @@ enum InlineNotesExporter {     ) -> String {         // Phase 0: Strip existing comment tags for a clean slate (Req 8.7)         let cleanSource = MarkdownBlockParser.stripCommentTags(rawSource)+        let cleanChars = Array(cleanSource)          // Collect document-level notes first         let documentNotes = collectNotes(@@ -103,7 +104,8 @@ enum InlineNotesExporter {                 )                 if !rowNotes.isEmpty {                     let content = formatTableRowNotes(rowNotes)-                    injections.append((mapped.endOffset, content, 0))+                    let offset = textEndOffset(in: cleanChars, blockEnd: mapped.endOffset)+                    injections.append((offset, content, 0))                 }             } @@ -116,7 +118,8 @@ enum InlineNotesExporter {             switch mapped.kind {             case .topLevel:                 let content = formatTopLevelNotes(notes)-                injections.append((mapped.endOffset, content, 0))+                let offset = textEndOffset(in: cleanChars, blockEnd: mapped.endOffset)+                injections.append((offset, content, 0))                  // Inject comment tags for eligible notes                 for note in tagEligible {@@ -550,6 +553,26 @@ enum InlineNotesExporter {         return (expStart, expEnd)     } +    // MARK: - Injection Offset Normalisation++    /// Returns the offset just past a top-level block's text content, skipping+    /// any trailing newlines that swift-markdown included in the block's range.+    ///+    /// A block's range is context-dependent: swift-markdown includes a list's+    /// trailing newline in its range when another block follows it (e.g. the+    /// injected `[!COMMENT]` blockquote) but excludes it for the same list at+    /// end-of-document. A note's `\n\n` separator must therefore anchor to the+    /// block's last text character, not its raw `endOffset`, or a list-anchored+    /// note gains a blank line on every export→import→export cycle. Paragraphs+    /// already exclude the trailing newline, so this is a no-op for them. (T-1440)+    private static func textEndOffset(in chars: [Character], blockEnd: Int) -> Int {+        var offset = min(blockEnd, chars.count)+        while offset > 0 && chars[offset - 1] == "\n" {+            offset -= 1+        }+        return offset+    }+     // MARK: - Apply Operations      private static func applyOperations(
prismTests/ExportImportRoundTripPropertyTests.swift Modified +24 / -4
diff --git a/prismTests/ExportImportRoundTripPropertyTests.swift b/prismTests/ExportImportRoundTripPropertyTests.swiftindex 4aed95a..81dbf67 100644--- a/prismTests/ExportImportRoundTripPropertyTests.swift+++ b/prismTests/ExportImportRoundTripPropertyTests.swift@@ -24,7 +24,13 @@ struct MarkdownDocumentGenerator {     mutating func randomDocument() -> String {         let blockCount = Int.random(in: 1...10, using: &rng)         let blocks = (0..<blockCount).map { _ in randomBlock() }-        return blocks.joined(separator: "\n\n")+        // Terminate with a trailing newline so fixtures match real markdown+        // files. A note injected at the last block's end offset is only+        // byte-stable across re-export when the document ends with a newline:+        // without one, the first export turns "text" into "text\n\n> [!COMMENT]…"+        // and stripping the comment on re-import can only recover "text\n", not+        // "text", so the re-export gains a trailing newline.+        return blocks.joined(separator: "\n\n") + "\n"     }      private mutating func randomBlock() -> String {@@ -163,6 +169,13 @@ struct ExportImportRoundTripPropertyTests {          var reimportedNotes: [String: [BlockNote]] = [:]         for noteData in extractionResult.importedNotes {+            // Reconstruct the reimported note the way the production import path+            // does (ImportedNotesProcessor.process): preserve the parsed id as+            // both exportId and noteHash, and the parsed timestamp as createdAt.+            // Re-deriving a hash from a fresh UUID() or using Date() here would+            // make the re-exported `id=`/`ts=` fields drift, breaking idempotency+            // for reasons unrelated to the exporter under test.+            let timestamp = noteData.timestamp ?? Date()             let note = BlockNote(                 id: UUID(),                 blockId: noteData.anchorBlockId,@@ -170,9 +183,13 @@ struct ExportImportRoundTripPropertyTests {                 sectionHeading: nil,                 content: noteData.content,                 status: noteData.isResolved ? .resolved : .active,-                createdAt: Date(),-                modifiedAt: Date(),-                author: noteData.author+                createdAt: timestamp,+                modifiedAt: timestamp,+                author: noteData.author,+                noteHash: noteData.noteId,+                threadId: noteData.threadId,+                extraFields: noteData.extraFields,+                exportId: noteData.noteId             )             reimportedNotes[noteData.anchorBlockId, default: []].append(note)         }@@ -411,7 +428,8 @@ struct ExportImportRoundTripPropertyTests {         ]          let content = unicodeContents[seed % unicodeContents.count]-        let source = "Test paragraph."+        // Trailing newline matches real markdown files (see randomDocument()).+        let source = "Test paragraph.\n"         let blocks = MarkdownBlockParser.parse(source)          guard let firstBlock = blocks.first else { return }
prismTests/DiagramWindowManagerTests.swift Modified +18 / -1
diff --git a/prismTests/DiagramWindowManagerTests.swift b/prismTests/DiagramWindowManagerTests.swiftindex ddd25c1..6e2fe9f 100644--- a/prismTests/DiagramWindowManagerTests.swift+++ b/prismTests/DiagramWindowManagerTests.swift@@ -11,6 +11,20 @@ import Foundation import Testing @testable import prism +/// Test double that records miniaturize/deminiaturize requests.+///+/// In a headless test environment there is no window server, so the real+/// `NSWindow.miniaturize(_:)`/`deminiaturize(_:)` never flip `isMiniaturized`.+/// This spy records the requested state instead, so the minimize/restore tests+/// verify the manager's window-selection logic deterministically rather than+/// depending on live window-server behaviour (T-1441).+private final class SpyDiagramWindow: NSWindow {+    private var miniaturizedState = false+    override var isMiniaturized: Bool { miniaturizedState }+    override func miniaturize(_ sender: Any?) { miniaturizedState = true }+    override func deminiaturize(_ sender: Any?) { miniaturizedState = false }+}+ /// Tests for DiagramWindowManager. /// /// Requirements covered:@@ -33,11 +47,14 @@ struct DiagramWindowManagerTests {      /// Creates a mock NSWindow for testing.     ///+    /// Returns a `SpyDiagramWindow` so minimize/restore tests can assert on the+    /// recorded miniaturization state without a live window server (T-1441).+    ///     /// `isReleasedWhenClosed = false` is critical: NSWindow's default is to     /// auto-release itself on `close()`, which over-releases the local test     /// reference and faults `objc_release` when the test scope unwinds.     private func makeMockWindow() -> NSWindow {-        let window = NSWindow(+        let window = SpyDiagramWindow(             contentRect: CGRect(x: 100, y: 100, width: 600, height: 450),             styleMask: [.titled, .closable, .resizable],             backing: .buffered,
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 6ce3ca6..5e0f4ae 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Notes anchored to a **list block** no longer gain a blank line on every export→import→export cycle (T-1440). `InlineNotesExporter` injected a top-level note's `\n\n` separator at the block's `endOffset`, but swift-markdown gives a list a context-dependent range — its trailing newline is *included* when another block follows it (the injected `[!COMMENT]` blockquote) but *excluded* for the same list at end-of-document — so the separator drifted and grew unbounded across re-exports. A new `textEndOffset()` helper anchors top-level note and table-row injections at the block's last text character (trimming trailing newlines); it is a no-op for paragraphs, whose range already excludes the trailing newline. This greens `ExportImportRoundTripPropertyTests`, whose property assertions surfaced the bug. Two test-fidelity fixes accompany it: the suite's `roundTrip()` helper now reconstructs reimported notes the way production's `ImportedNotesProcessor` does (preserving `noteData.noteId` as `exportId`/`noteHash` and `noteData.timestamp` as `createdAt`, instead of a fresh `UUID()`+`Date()` that made `id=`/`ts=` drift each round-trip), and `MarkdownDocumentGenerator.randomDocument()` plus the `unicodeInNotes` literal now newline-terminate their sources to match real markdown files.+- `DiagramWindowManagerTests` minimize/restore cases (`minimizeWindowsMinimizesAllWindowsOwnedByDocument`, `minimizeWindowsOnlyAffectsSpecifiedDocument`, `restoreWindowsOnlyAffectsSpecifiedDocument`) no longer fail in the headless macOS test environment (T-1441). They asserted on live `NSWindow.isMiniaturized`, which never flips without a window server. A new `SpyDiagramWindow: NSWindow` test double overrides `miniaturize`/`deminiaturize`/`isMiniaturized` to record the requested state, so the tests verify the manager's window-selection logic deterministically. No production code changed for this fix. - macOS unit-test runs no longer crash the xctest runner with a SIGSEGV that masqueraded as ~11,000 "signal trap" collateral failures. Five root causes addressed: (1) `DiagramWindowManager`/`ImageDetailWindowManager` replaced the one global `NSWindow.willCloseNotification` observer (`object: nil`) with per-window observers (`object: window`) installed at `registerWindow` and removed at `unregisterWindow`/`deinit`, so notifications from other documents/tests never reach an unrelated manager; (2) `DocumentIdentifierResolver.resolve(forRemoteURL:)` now derives the path from `URLComponents.path` instead of `URL.path` — the latter silently collapses a trailing slash, causing `https://example.com/docs/` and `https://example.com/docs` to share one identifier; (3) `SearchCoordinator.recomputeMatchCounts()` no longer fires a `#if DEBUG assert(blocks not empty)` that crashed legitimate test scenarios where a search query is set before the document's blocks have been parsed (the guard below already handles empty blocks gracefully); (4) `DiagramWindowManagerTests.makeMockWindow()` and the deinit-observer-test sentinel set `isReleasedWhenClosed = false` because NSWindow auto-releases on `close()` by default, leaving the test's local `let window` as a dangling pointer that faulted `objc_release` when the test scope unwound; (5) `make test-quick` now passes `-testPlan prism -only-test-configuration "en (base)" -parallel-testing-worker-count 1` so the macOS unit run no longer fans out four parallel workers fighting for shared global state (`MockURLProtocol.handler`, `NotificationCenter`, `NSApp.windows`). Locale-matrix coverage is still available via `make test-locales`. - Prism no longer hangs intermittently while scrolling continuously through a document (T-1289). The `.onScrollGeometryChange` action handlers introduced in PR #252 wrote `scrollPercentage`, `pendingRestorePercentage`, and the raw-source `scrollOffset`/`contentHeight` on every scroll frame, but no SwiftUI view reactively reads any of them — they're only sampled at user-action time. More importantly, `KeyboardScrollController.contentHeight` was observed by `DocumentReaderView.body` via `makeDocumentActions` reading `active.canScroll`, so every `LazyVStack` block realisation (each of which grew `contentSize.height` and triggered the inequality-guarded `contentHeight` write) cascaded into a full `DocumentReaderView` body re-evaluation. Two-layer fix: mark the four "no body reader" properties `@ObservationIgnored`, and refactor `KeyboardScrollController` so `contentHeight` is computed over an `@ObservationIgnored` backing store while `canScroll` is a stored observable Bool maintained by the setter — `DocumentReaderView.body` now invalidates only on the two `canScroll` transitions per session, not per realisation. `prismTests/ScrollPositionObservationTests.swift` locks both contracts in with `withObservationTracking`-based assertions so a future refactor can't silently re-introduce the regression. - Switching between rendered and raw markdown views no longer resets the scroll position to the top. The scroll percentage is now snapshotted at toggle time and restored on the receiving view (best-effort; raw lines and rendered blocks aren't 1:1 but the position is approximately preserved in both directions). Same mechanism powers cross-session restore on document re-open.

Things to double-check

List-item-anchored note round-trips (T-1457).

The .listItem path was deliberately not touched. Anyone picking up T-1457 should verify whether list-item notes followed by sibling content exhibit the same newline growth and, if so, extend textEndOffset() there with dedicated coverage.

Broader pre-existing notes/export failures on main (T-1457).

This branch does not green InlineNotesRoundTripTests, CommentTagRoundTripTests, DebugBlockquoteFormatTests, or NotesManagerImportedNotesTests — confirmed failing on a HEAD baseline independent of this change (stale V2 id=/ts= format expectations + the same helper drift). A full make test-quick will still show these.