The card menu learns to tag — a labels submenu of twelve checkmarks, and Copy Link moves in beside its mirror
Right-clicking a card now offers Labels: up to twelve rows ranked by how many cards on the board carry each one, tie-broken by what this user reached for last, each a checkmark that says whether *this* card already has it. More… opens the whole inventory beside the card, in lookup order, with a field that mints a name the board has never used. The rows deliberately do not widen to the selection the way Copy, Cut and Send to Trash do. A checkmark is a claim about one card, and three cards where two carry `bug` have no honest checked state — the rows that widen are the ones that say what they will do rather than what is already true. It is also what keeps the menu cheap: a context menu's contents are rebuilt on every ordinary pass of every card face, so reading the selection there would resubscribe the whole board. The board-sized half of the ranking is cached on the store and gated on equality; what runs per face is bounded by how many distinct labels exist, not by how many cards do. Copy Link leaves the first group for a new Copy Special submenu, mirroring Paste Special — the placement the owner's latest layout asks for, and the one the row's own note has been waiting on since it shipped as a same-day deviation. It keeps its VoiceOver action even so: it is an action that changed doors, not a door. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -585,6 +585,138 @@ struct LabelStoreWriteTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The More… dialog's session
|
||||
|
||||
@MainActor
|
||||
@Suite("Labels ▸ the More… dialog's lifecycle")
|
||||
struct LabelEditorSessionTests {
|
||||
|
||||
@Test("Begin aims it at one card; a second Begin re-aims rather than stacking")
|
||||
func beginAndDiscard() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.transient.labelEditor == nil)
|
||||
store.transient.beginLabelEditor(forCard: clipboardCard1)
|
||||
#expect(store.transient.labelEditor?.cardID == clipboardCard1)
|
||||
store.transient.beginLabelEditor(forCard: clipboardCard2)
|
||||
#expect(store.transient.labelEditor?.cardID == clipboardCard2, "one dialog, re-aimed")
|
||||
store.transient.discardLabelEditor()
|
||||
#expect(store.transient.labelEditor == nil)
|
||||
}
|
||||
|
||||
@Test("The reload rule takes it down when its card is no longer a live board card")
|
||||
func theReloadRule() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
#expect(LabelEditorSession(cardID: clipboardCard1).resolved(against: snapshot) != nil)
|
||||
// A trashed card resolves nowhere on the board side, which is exactly right: the write the
|
||||
// dialog offers (`BoardStore.setLabels`) refuses a trashed card.
|
||||
#expect(LabelEditorSession(cardID: clipboardCard3).resolved(against: snapshot) == nil)
|
||||
#expect(LabelEditorSession(cardID: ItemID(rawValue: Ident.indexless))
|
||||
.resolved(against: snapshot) == nil)
|
||||
}
|
||||
|
||||
@Test("A card that leaves the board while the dialog is open dismisses it")
|
||||
func aVanishedCardDismisses() async throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.beginLabelEditor(forCard: clipboardCard1)
|
||||
|
||||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(store.transient.labelEditor == nil)
|
||||
// And the style session's own lifecycle is untouched by the new neighbour.
|
||||
#expect(store.transient.styleEditor == nil)
|
||||
}
|
||||
|
||||
@Test("The two sessions are independent — neither `begin` clears the other")
|
||||
func theTwoSessionsAreIndependent() throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginStyleEditor(for: .items([clipboardCard1]))
|
||||
store.transient.beginLabelEditor(forCard: clipboardCard1)
|
||||
#expect(store.transient.styleEditor != nil)
|
||||
#expect(store.transient.labelEditor != nil)
|
||||
store.transient.discardLabelEditor()
|
||||
#expect(store.transient.styleEditor != nil, "discarding one leaves the other")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The menu's funnel
|
||||
|
||||
@MainActor
|
||||
@Suite("Labels ▸ the command funnel")
|
||||
struct LabelCommandTests {
|
||||
|
||||
private func settle(_ store: BoardStore) async {
|
||||
store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
@Test("Toggle adds what is missing and removes what is there")
|
||||
func toggling() async throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
|
||||
|
||||
#expect(LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents))
|
||||
await settle(store)
|
||||
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c", "spike"])
|
||||
|
||||
#expect(LabelCommand.toggle("SPIKE", onCard: clipboardCard1, in: store, recents: recents))
|
||||
await settle(store)
|
||||
#expect(store.labels(ofCard: clipboardCard1) == ["a", "b", "c"], "case-insensitive, like everywhere")
|
||||
}
|
||||
|
||||
@Test("An apply records the MRU; a removal deliberately does not")
|
||||
func recording() async throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
|
||||
|
||||
LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)
|
||||
#expect(recents.labels == ["spike"])
|
||||
await settle(store)
|
||||
|
||||
LabelCommand.toggle("spike", onCard: clipboardCard1, in: store, recents: recents)
|
||||
#expect(recents.labels == ["spike"], "taking a label off is not reaching for it")
|
||||
|
||||
// And an add onto a card that already carries it still records — the user reached for it.
|
||||
await settle(store)
|
||||
LabelCommand.add("a", onCard: clipboardCard1, in: store, recents: recents)
|
||||
#expect(recents.labels == ["a", "spike"])
|
||||
}
|
||||
|
||||
@Test("A typed name lands in the board's own spelling")
|
||||
func spellingResolution() async throws {
|
||||
let fixture = try makeClipboardBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let recents = LabelRecents(defaults: UserDefaults(suiteName: "LabelCommandTests-\(UUID().uuidString)")!)
|
||||
|
||||
// The board already spells it `a`; typing `A` must not mint a second variant.
|
||||
#expect(LabelCommand.resolved("A", in: store) == "a")
|
||||
#expect(LabelCommand.resolved("brand-new", in: store) == "brand-new", "a new name is the typist's")
|
||||
#expect(LabelCommand.resolved(" ", in: store) == nil)
|
||||
|
||||
LabelCommand.add("A", onCard: clipboardCard2, in: store, recents: recents)
|
||||
await settle(store)
|
||||
#expect(store.labelIndex.names.allSatisfy { $0 != "A" }, "no second spelling entered the universe")
|
||||
#expect(recents.labels == ["a"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The sidebar's picker
|
||||
|
||||
@Suite("Labels ▸ the sidebar's add field")
|
||||
|
||||
Reference in New Issue
Block a user