import Foundation import Testing @testable import Kanban /// **The card window's ⌘F over the comments pane** — 05-card-window.md ▸ Preview: /// /// > Edit ▸ Find (⌘F) is find-in-text here — the standard find bar over the focused surface (Preview's /// > selectable text, the Edit editor, raw source — and the comments pane, where it searches the whole /// > rendered thread, `.draft` excluded; the composer and an inline comment edit are their own focused /// > text surfaces with the editor's ordinary find). /// /// Two claims and therefore two halves: **where the key goes** (a pure routing rule over the pane's /// focus, `CardWindowFind`), and **what the find does once it is there** (a pure search over the /// rendered thread plus a session that steps through it, `CommentThreadSearch` / `CommentThreadFind`). /// /// The thread itself is never `.draft` or `comments/.trash/` here for free — those are excluded from /// the listing by the loader (`CommentThreadTests`), so a find that searches "the thread" cannot see /// them by construction. // MARK: - Fixtures private func row(_ id: String, _ text: String) -> CommentThreadRow { CommentThreadRow(id: ItemID(rawValue: id), text: text) } private func comment(_ id: String, body: String) -> Kanban.Comment { Kanban.Comment( id: ItemID(rawValue: id), schema: .valid(1), author: .missing, created: .missing, modified: .missing, modifiedBy: .missing, attachments: [], document: FrontmatterDocument(body: body) ) } // MARK: - Routing @Suite("Comment find ▸ where ⌘F goes") struct CardWindowFindRouteTests { @Test("A focused comment routes to the thread; a focused authoring surface to its own editor") func focusDecides() { #expect(CardWindowFind.route(paneFocus: .thread, isThreadFindShowing: false, hasBody: true) == .thread) #expect(CardWindowFind.route(paneFocus: .authoring, isThreadFindShowing: false, hasBody: true) == .authoring) } @Test("With the pane unfocused it is the body's find, exactly as before the pane existed") func bodyIsTheDefault() { #expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: false, hasBody: true) == .body) #expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: false, hasBody: false) == nil) } @Test("An open find bar outranks an absent focus — a second ⌘F returns to the search field") func theOpenBarKeepsTheKey() { // Raising the bar takes the keyboard out of the thread and into the bar's own field, so the // pane reports no focus; without this clause the next ⌘F would fall through to the body. #expect(CardWindowFind.route(paneFocus: nil, isThreadFindShowing: true, hasBody: true) == .thread) } @Test("An authoring surface outranks even an open bar — the user is typing in an editor") func authoringWins() { #expect(CardWindowFind.route(paneFocus: .authoring, isThreadFindShowing: true, hasBody: true) == .authoring) } } // MARK: - The search @Suite("Comment find ▸ the search") struct CommentThreadSearchTests { @Test("Matches are in reading order: rows in order, hits within a row left to right") func readingOrder() { let matches = CommentThreadSearch.matches( in: [row(CommentIdent.one, "kestrel and kestrel"), row(CommentIdent.two, "a kestrel")], query: "kestrel" ) #expect(matches.count == 3) #expect(matches[0] == CommentThreadMatch(comment: ItemID(rawValue: CommentIdent.one), range: NSRange(location: 0, length: 7))) #expect(matches[1].comment == ItemID(rawValue: CommentIdent.one)) #expect(matches[1].range.location == 12) #expect(matches[2].comment == ItemID(rawValue: CommentIdent.two)) } @Test("The comparison folds case and diacritics — the board query's own rule") func folding() { let matches = CommentThreadSearch.matches(in: [row(CommentIdent.one, "A Résumé")], query: "resume") #expect(matches.count == 1) // The range is in the *unfolded* text, which is what makes the highlight land on what is drawn. #expect(matches[0].range == NSRange(location: 2, length: 6)) } @Test("Overlapping occurrences are one hit — NSTextFinder's answer, and the body's ⌘F's") func overlapping() { // Two finds in one window must not disagree about how many times "aa" occurs in "aaa". #expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "aaa")], query: "aa").count == 1) #expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "aaaa")], query: "aa").count == 2) } @Test("An empty query finds nothing — the bar opens empty and says nothing") func emptyQuery() { #expect(CommentThreadSearch.matches(in: [row(CommentIdent.one, "anything")], query: "").isEmpty) } @Test("Stepping wraps in both directions — which is what makes it one find across rows") func stepping() { #expect(CommentThreadSearch.step(from: 0, count: 3, forward: true) == 1) #expect(CommentThreadSearch.step(from: 2, count: 3, forward: true) == 0) #expect(CommentThreadSearch.step(from: 0, count: 3, forward: false) == 2) #expect(CommentThreadSearch.step(from: 0, count: 1, forward: true) == 0) #expect(CommentThreadSearch.step(from: 0, count: 0, forward: true) == 0) } @Test("The counter reads as a find bar's does, and says so when there is nothing") func status() { #expect(CommentThreadSearch.status(index: 0, count: 3, query: "k") == "1 of 3") #expect(CommentThreadSearch.status(index: 2, count: 3, query: "k") == "3 of 3") #expect(CommentThreadSearch.status(index: 0, count: 0, query: "k") == "No matches") #expect(CommentThreadSearch.status(index: 0, count: 0, query: "") == "") } } // MARK: - The session @MainActor @Suite("Comment find ▸ the session") struct CommentThreadFindTests { private let pointSize: CGFloat = 13 @Test("⌘F raises the bar and asks for the keyboard, every time") func invoking() { let find = CommentThreadFind() #expect(!find.isShowing) find.invoke() #expect(find.isShowing) #expect(find.focusRequests == 1) // A second press is a re-focus, not a dismissal — `FindCommand`'s "focus, not toggle" rule. find.invoke() #expect(find.isShowing) #expect(find.focusRequests == 2) } @Test("Done keeps the query — re-opening lands on the search the user was running") func dismissKeepsTheQuery() { let find = CommentThreadFind() find.invoke() find.setThread([comment(CommentIdent.one, body: "kestrel")], pointSize: 13) find.query = "kestrel" #expect(find.matches(in: ItemID(rawValue: CommentIdent.one)).count == 1) find.dismiss() #expect(!find.isShowing) #expect(find.query == "kestrel") // The marks come off the thread with the bar — a dismissed find must not leave a highlighted // thread behind, even though the search itself is remembered. #expect(find.matches(in: ItemID(rawValue: CommentIdent.one)).isEmpty) #expect(find.currentMatch == nil) } @Test("It searches the *rendered* thread — markup is not what the reader sees, so it is not searched") func searchesRenderedText() { let find = CommentThreadFind() find.setThread([comment(CommentIdent.one, body: "A **bold** remark")], pointSize: pointSize) find.query = "bold" #expect(find.matches.count == 1) // The asterisks are markup the renderer consumed; a user looking at the row cannot see them. find.query = "**bold**" #expect(find.matches.isEmpty) } @Test("Every comment is searched, mounted or not — the rows come from the thread, not the views") func spansTheWholeThread() { let find = CommentThreadFind() find.invoke() find.setThread( [ comment(CommentIdent.one, body: "first kestrel"), comment(CommentIdent.two, body: "nothing here"), comment(CommentIdent.three, body: "second kestrel") ], pointSize: pointSize ) find.query = "kestrel" #expect(find.matches.count == 2) #expect(find.matches.map(\.comment) == [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)]) #expect(find.matches(in: ItemID(rawValue: CommentIdent.two)).isEmpty) #expect(find.matches(in: ItemID(rawValue: CommentIdent.three)).count == 1) } @Test("Next crosses rows, and wraps — one find, not one per comment") func steppingCrossesRows() { let find = CommentThreadFind() find.invoke() find.setThread( [comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel")], pointSize: pointSize ) find.query = "kestrel" #expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.one)) find.step(forward: true) #expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.two)) find.step(forward: true) #expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.one)) find.step(forward: false) #expect(find.currentMatch?.comment == ItemID(rawValue: CommentIdent.two)) } @Test("A new query is a new search, so it starts at the first hit") func queryChangeResets() { let find = CommentThreadFind() find.setThread( [comment(CommentIdent.one, body: "kestrel kestrel kestrel")], pointSize: pointSize ) find.query = "kestrel" find.step(forward: true) find.step(forward: true) #expect(find.status == "3 of 3") find.query = "kestre" #expect(find.status == "1 of 3") } @Test("A thread re-read under an open bar keeps the user's place") func reloadPreservesPosition() { let find = CommentThreadFind() find.setThread( [comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel")], pointSize: pointSize ) find.query = "kestrel" find.step(forward: true) #expect(find.status == "2 of 2") // An agent files a third comment while the user is stepping through the search. find.setThread( [ comment(CommentIdent.one, body: "kestrel"), comment(CommentIdent.two, body: "kestrel"), comment(CommentIdent.three, body: "kestrel") ], pointSize: pointSize ) #expect(find.status == "2 of 3") } @Test("A re-read that removes the matches clamps rather than pointing past the end") func reloadClamps() { let find = CommentThreadFind() find.invoke() find.setThread([comment(CommentIdent.one, body: "kestrel kestrel")], pointSize: pointSize) find.query = "kestrel" find.step(forward: true) find.setThread([comment(CommentIdent.one, body: "a falcon instead")], pointSize: pointSize) #expect(find.matches.isEmpty) #expect(find.currentMatch == nil) #expect(find.status == "No matches") } } // MARK: - The pane's routing, end to end @MainActor @Suite("Comment find ▸ the pane's focus") struct CommentPaneFindRoutingTests { @Test("A focused comment sends ⌘F to the thread's bar") func threadFocusRaisesTheBar() { let comments = CardComments() comments.focusEntered(.thread) #expect(comments.invokeFind(hasBody: true)) #expect(comments.find.isShowing) } @Test("A focused authoring surface sends ⌘F to that editor's own find bar") func authoringFocusRunsTheEditorsFind() { let comments = CardComments() var editorFinds = 0 comments.focusEntered(.authoring) { editorFinds += 1 } #expect(comments.invokeFind(hasBody: true)) #expect(editorFinds == 1) #expect(!comments.find.isShowing) } @Test("With nothing in the pane focused, ⌘F is declined and the body keeps it") func unfocusedDeclines() { let comments = CardComments() #expect(!comments.invokeFind(hasBody: true)) #expect(!comments.find.isShowing) } @Test("A late resignation from a surface that no longer holds focus is ignored") func lateResignationIsIgnored() { // AppKit resigns the outgoing responder before the incoming one becomes first, but the // guard covers the case where it does not: clicking from a comment into the composer must // not leave the pane reporting no focus at all. let comments = CardComments() comments.focusEntered(.thread) comments.focusEntered(.authoring) {} comments.focusLeft(.thread) #expect(comments.paneFocus == .authoring) } @Test("Losing focus for real clears it, and the editor's find goes with it") func focusLeftClears() { let comments = CardComments() comments.focusEntered(.authoring) {} comments.focusLeft(.authoring) #expect(comments.paneFocus == nil) #expect(!comments.invokeFind(hasBody: true)) } @Test("Find Next and Find Previous validate on the thread's bar alone") func steppingRowsValidate() { // The two other finds in this window are `NSTextFinder`'s, and `NSTextView` answers ⌘G through // the responder chain — an enabled menu item would fire first and break the stepping it exists // to provide (`FindSteppingCommands`). #expect(!FindSteppingCommands.isEnabled(nil)) let comments = CardComments() #expect(!FindSteppingCommands.isEnabled(comments)) comments.focusEntered(.thread) _ = comments.invokeFind(hasBody: true) #expect(FindSteppingCommands.isEnabled(comments)) comments.find.dismiss() #expect(!FindSteppingCommands.isEnabled(comments)) } }