Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
344 lines
14 KiB
Swift
344 lines
14 KiB
Swift
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))
|
|
}
|
|
}
|