Polish clipboard panel UX and interactions

This commit is contained in:
Akshay Kolli
2026-07-09 19:32:57 -04:00
parent 23cd8b64a9
commit 52f712eb73
18 changed files with 2109 additions and 3273 deletions

View File

@@ -1,4 +1,5 @@
import AppKit
import Carbon
import XCTest
@testable import ClipBored
@@ -140,59 +141,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
}
func testPanelFrameIgnoresLegacyPreferredResizableHeight() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let frames = ClipboardPanelController.panelFrames(
forScreenFrame: screenFrame,
visibleFrame: visibleFrame,
preferredHeight: 600
)
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
XCTAssertEqual(frames.shown.height, 957)
XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1)
}
func testPanelFrameLegacyPreferredHeightDoesNotAffectSideShelf() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let tooTall = ClipboardPanelController.panelFrames(
forScreenFrame: screenFrame,
visibleFrame: visibleFrame,
preferredHeight: 900
)
let tooShort = ClipboardPanelController.panelFrames(
forScreenFrame: screenFrame,
visibleFrame: visibleFrame,
preferredHeight: 120
)
XCTAssertEqual(tooTall.shown.height, 982)
XCTAssertEqual(tooShort.shown.height, 982)
XCTAssertEqual(tooTall.shown.width, 336)
XCTAssertEqual(tooShort.shown.width, 336)
}
func testPanelFrameLegacyPreferredHeightDoesNotOverflowTinyVisibleFrame() {
let screenFrame = CGRect(x: 0, y: 0, width: 640, height: 320)
let visibleFrame = CGRect(x: 0, y: 48, width: 640, height: 220)
let frames = ClipboardPanelController.panelFrames(
forScreenFrame: screenFrame,
visibleFrame: visibleFrame,
preferredHeight: 620
)
XCTAssertEqual(frames.shown.height, 268)
XCTAssertEqual(frames.shown.width, 320)
XCTAssertLessThanOrEqual(frames.shown.height, visibleFrame.maxY - screenFrame.minY)
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
}
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
@@ -234,10 +182,10 @@ final class ClipboardPanelControllerTests: XCTestCase {
controller.debugSetSearchFieldText("stale query")
drainMainQueue()
XCTAssertEqual(controller.debugSearchFieldText, "stale query")
XCTAssertEqual(controller.debugSearchFieldWidth, 164, accuracy: 0.5)
XCTAssertEqual(controller.debugSearchFieldWidth, 238, accuracy: 1)
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips")
XCTAssertTrue(controller.debugSearchFieldIsVisible)
XCTAssertFalse(controller.debugSearchIconButtonIsVisible)
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
controller.show(preferredScreen: screen)
defer { controller.hide(immediate: true) }
@@ -277,22 +225,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(plan.bottomSafeInset, 18)
}
func testReflowPlanIgnoresLegacyPreferredResizableHeight() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let plan = ClipboardPanelController.reflowPlan(
forScreenFrame: screenFrame,
visibleFrame: visibleFrame,
preferredHeight: 600
)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.height, 957)
XCTAssertEqual(plan.frame.width, 336)
XCTAssertEqual(plan.bottomSafeInset, 20)
}
func testReflowPlanTracksSideDockVisibleFrameWithoutBottomInsetInflation() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957)
@@ -496,6 +428,32 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command))
}
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
let binding = AppConfiguration.defaultSettingsShortcut
XCTAssertTrue(
ClipboardPanelController.matchesShortcut(
keyCode: UInt16(kVK_ANSI_Comma),
modifiers: .command,
binding: binding
)
)
XCTAssertFalse(
ClipboardPanelController.matchesShortcut(
keyCode: UInt16(kVK_ANSI_Comma),
modifiers: [.command, .shift],
binding: binding
)
)
XCTAssertFalse(
ClipboardPanelController.matchesShortcut(
keyCode: UInt16(kVK_ANSI_Period),
modifiers: .command,
binding: binding
)
)
}
func testModifiedShortcutsMapToPanelActions() {
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)

View File

@@ -206,6 +206,205 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.debugCollectionCountIndexedLookupCount, 1)
}
func testSearchMatchesAreEvaluatedOnceAcrossVisibleItemsCountsAndCategoryChanges() {
let settings = makeSettings()
settings.maxHistoryItems = 120
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<100 {
var item = makeTextItem(
index.isMultiple(of: 5) ? "shared search needle \(index)" : "unmatched history \(index)",
createdAt: Date(timeIntervalSince1970: Double(index))
)
if index.isMultiple(of: 2) {
item.collectionName = "Client Work"
}
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 100)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
let counts = viewModel.collectionCountSummary()
XCTAssertEqual(counts.count(for: .text), 20)
XCTAssertEqual(counts.count(named: "Client Work"), 10)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 1)
viewModel.selectSortMode(.text, extending: true)
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 3)
viewModel.searchText = "shared"
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 200)
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
XCTAssertEqual(viewModel.debugSearchDocumentCacheHitCount, 100)
}
func testEquivalentDiacriticSearchReusesVisibleAndCollectionCountCaches() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("Résumé draft", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("Meeting note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "resume"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
_ = viewModel.collectionCountSummary()
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
viewModel.searchText = " RÉSUMÉ "
_ = viewModel.collectionCountSummary()
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
}
func testCategoryFilterSelectionIsBuiltOncePerMutationAndReusedForChipStateQueries() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("category state", createdAt: Date(timeIntervalSince1970: 100)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectSortMode(.text, extending: true)
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
for _ in 0..<20 {
for mode in ClipboardSortMode.allCases {
_ = viewModel.isSortModeCategorySelected(mode)
}
_ = viewModel.isCollectionCategorySelected(named: "Client Work")
_ = viewModel.canShowVisibleItemsInClipboard
}
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
}
func testRepeatedRecomputesDoNotRescanUnchangedStackMembership() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("stacked needle", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.toggleSelectedStackMembership()
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "needle"
viewModel.clearSearch()
viewModel.selectSortMode(.text)
viewModel.selectSortMode(.mostRecent)
XCTAssertEqual(viewModel.stackCount, 1)
XCTAssertEqual(viewModel.debugStackPruneScanCount, 0)
}
func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("hover target", createdAt: Date(timeIntervalSince1970: 100)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
var selectedIndexCallbackCount = 0
var selectedItemsCallbackCount = 0
viewModel.onSelectedIndexChanged = { _ in selectedIndexCallbackCount += 1 }
viewModel.onSelectedItemsChanged = { selectedItemsCallbackCount += 1 }
viewModel.selectItem(at: 0, mode: .hover)
let callbackCountsAfterFirstHover = (selectedIndexCallbackCount, selectedItemsCallbackCount)
viewModel.selectItem(at: 0, mode: .hover)
XCTAssertEqual(selectedIndexCallbackCount, callbackCountsAfterFirstHover.0)
XCTAssertEqual(selectedItemsCallbackCount, callbackCountsAfterFirstHover.1)
}
func testAsyncThumbnailLoadingRunsOffMainCoalescesRequestsAndCompletesOnMain() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let loaderStarted = expectation(description: "thumbnail loader started")
let completionsFinished = expectation(description: "thumbnail completions")
completionsFinished.expectedFulfillmentCount = 2
let loaderGate = DispatchSemaphore(value: 0)
let loaderStateLock = NSLock()
var loaderCallCount = 0
var loaderRanOnMain = true
let viewModel = ClipboardPanelViewModel(
store: store,
settings: settings,
cacheService: cacheService,
thumbnailLoader: { _ in
loaderStateLock.lock()
loaderCallCount += 1
loaderRanOnMain = Thread.isMainThread
loaderStateLock.unlock()
loaderStarted.fulfill()
_ = loaderGate.wait(timeout: .now() + 2)
return NSImage(size: NSSize(width: 20, height: 20))
}
)
var item = makeTextItem("thumbnail request", createdAt: Date(timeIntervalSince1970: 100))
item.kind = .image
item.thumbnailPath = "/tmp/thumbnail-request.png"
var completionMainThreadValues: [Bool] = []
viewModel.loadThumbnail(for: item) { image in
completionMainThreadValues.append(Thread.isMainThread)
XCTAssertNotNil(image)
completionsFinished.fulfill()
}
wait(for: [loaderStarted], timeout: 1)
viewModel.loadThumbnail(for: item) { image in
completionMainThreadValues.append(Thread.isMainThread)
XCTAssertNotNil(image)
completionsFinished.fulfill()
}
loaderGate.signal()
wait(for: [completionsFinished], timeout: 2)
loaderStateLock.lock()
let finalLoaderCallCount = loaderCallCount
let finalLoaderRanOnMain = loaderRanOnMain
loaderStateLock.unlock()
XCTAssertEqual(finalLoaderCallCount, 1)
XCTAssertFalse(finalLoaderRanOnMain)
XCTAssertEqual(completionMainThreadValues, [true, true])
}
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() {
let settings = makeSettings()
let store = makeStore(settings: settings)
@@ -1141,6 +1340,50 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(collectionCallbackCount, 0)
}
func testCreatingUnselectedCollectionUpdatesChromeWithoutReloadingVisibleItems() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("visible note", createdAt: Date(timeIntervalSince1970: 100)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
var visibleCallbackCount = 0
var collectionCallbackCount = 0
viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 }
viewModel.onCollectionsChanged = { collectionCallbackCount += 1 }
viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8", selectAfterCreate: false)
XCTAssertEqual(viewModel.collectionNames, ["Client Work"])
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["visible note"])
XCTAssertEqual(visibleCallbackCount, 0)
XCTAssertEqual(collectionCallbackCount, 1)
}
func testExternalCollectionSettingsChangeUpdatesChromeWithoutReloadingVisibleItems() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("visible note", createdAt: Date(timeIntervalSince1970: 100)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
var visibleCallbackCount = 0
var collectionCallbackCount = 0
viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 }
viewModel.onCollectionsChanged = { collectionCallbackCount += 1 }
settings.ensureCollection(named: "Client Work", colorHex: "#0A9EB8")
XCTAssertEqual(viewModel.collectionNames, ["Client Work"])
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["visible note"])
XCTAssertEqual(visibleCallbackCount, 0)
XCTAssertEqual(collectionCallbackCount, 1)
}
func testSettingsChangesRefreshPanelSortAndImageTextSearch() {
let settings = makeSettings()
let cacheService = makeCacheService()
@@ -1190,6 +1433,44 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.collectionCount(for: .images), 1)
}
func testImageTextSearchSettingSkipsReloadWhenActiveQueryHasNoTextTokens() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let image = ClipboardItem(
id: UUID(),
kind: .image,
displayText: "Screenshot",
payload: "screenshot-path",
payloadHash: hash("screenshot-path"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
ocrText: "Receipt total"
)
store.upsert(image)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
var visibleCallbackCount = 0
viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 }
settings.includeImageTextInSearch = true
XCTAssertEqual(visibleCallbackCount, 0)
viewModel.searchText = "type:image"
XCTAssertEqual(visibleCallbackCount, 1)
visibleCallbackCount = 0
settings.includeImageTextInSearch = false
XCTAssertEqual(viewModel.visibleItems.map(\.id), [image.id])
XCTAssertEqual(visibleCallbackCount, 0)
}
func testAdjacentCollectionNavigationWrapsThroughPinboards() {
let settings = makeSettings()
let cacheService = makeCacheService()
@@ -1226,142 +1507,6 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.statusMessage, "No collections")
}
func testCompactModeToggleIsRemovedAndDoesNotPersist() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
XCTAssertFalse(viewModel.isCompactModeEnabled)
viewModel.toggleCompactMode()
XCTAssertFalse(settings.compactMode)
XCTAssertFalse(viewModel.isCompactModeEnabled)
XCTAssertEqual(viewModel.statusMessage, "Compact Mode was removed")
viewModel.toggleCompactMode()
XCTAssertFalse(settings.compactMode)
XCTAssertFalse(viewModel.isCompactModeEnabled)
XCTAssertEqual(viewModel.statusMessage, "Compact Mode was removed")
}
func testCreateTextClipAddsSearchableSelectedItem() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
viewModel.sortMode = .images
viewModel.searchText = "old query"
let now = Date(timeIntervalSince1970: 1_800)
let created = viewModel.createTextClip(" Launch note ", now: now)
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(created?.kind, .text)
XCTAssertEqual(created?.payload, "Launch note")
XCTAssertEqual(created?.displayText, "Launch note")
XCTAssertEqual(created?.sourceApp, AppConfiguration.appName)
XCTAssertEqual(created?.useCount, 0)
XCTAssertEqual(created?.createdAt, now)
XCTAssertEqual(viewModel.sortMode, .text)
XCTAssertEqual(viewModel.searchText, "")
XCTAssertEqual(viewModel.selectedItem?.id, created?.id)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "Launch note")
XCTAssertEqual(viewModel.statusMessage, "Created text clip")
}
func testCreateTextClipBatchesFilterResetIntoSingleVisibleReload() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let seed = makeTextItem("stack seed", createdAt: Date(timeIntervalSince1970: 100))
store.upsert(seed)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.selectItem(at: 0)
viewModel.toggleSelectedStackMembership()
viewModel.sortMode = .images
viewModel.selectStack()
viewModel.searchText = "old query"
XCTAssertTrue(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.sortMode, .images)
XCTAssertEqual(viewModel.searchText, "old query")
var visibleCallbackCount = 0
var searchTextCallbacks: [String] = []
var sortCallbacks: [ClipboardSortMode] = []
var collectionCallbackCount = 0
var stackCallbackCount = 0
viewModel.onVisibleItemsChanged = { _ in visibleCallbackCount += 1 }
viewModel.onSearchTextChanged = { searchTextCallbacks.append($0) }
viewModel.onSortModeChanged = { sortCallbacks.append($0) }
viewModel.onCollectionsChanged = { collectionCallbackCount += 1 }
viewModel.onStackChanged = { stackCallbackCount += 1 }
let created = viewModel.createTextClip("Launch note", now: Date(timeIntervalSince1970: 200))
XCTAssertEqual(created?.payload, "Launch note")
XCTAssertFalse(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.sortMode, .text)
XCTAssertEqual(viewModel.searchText, "")
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Launch note", "stack seed"])
XCTAssertEqual(viewModel.selectedItem?.id, created?.id)
XCTAssertEqual(visibleCallbackCount, 1)
XCTAssertEqual(searchTextCallbacks, [""])
XCTAssertEqual(sortCallbacks, [.text])
XCTAssertEqual(collectionCallbackCount, 0)
XCTAssertEqual(stackCallbackCount, 0)
XCTAssertEqual(viewModel.statusMessage, "Created text clip")
}
func testCreateTextClipAddsItemToActiveCollection() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8")
let created = viewModel.createTextClip("Pinboard note", now: Date(timeIntervalSince1970: 1_900))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(created?.collectionName, "Client Work")
XCTAssertEqual(store.items.first?.collectionName, "Client Work")
XCTAssertEqual(viewModel.selectedCollectionName, "Client Work")
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Pinboard note"])
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
}
func testCreateTextClipMergesDuplicateIntoActiveCollection() {
let settings = makeSettings()
settings.pruneDuplicates = true
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
var existing = makeTextItem("Reusable note", createdAt: Date(timeIntervalSince1970: 100))
existing.payloadHash = store.hashString(existing.payload)
store.upsert(existing)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.createCollection(named: "Client Work", colorHex: "#0A9EB8")
let created = viewModel.createTextClip("Reusable note", now: Date(timeIntervalSince1970: 2_000))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(created?.id, existing.id)
XCTAssertEqual(store.items.first?.id, existing.id)
XCTAssertEqual(store.items.first?.collectionName, "Client Work")
XCTAssertEqual(viewModel.selectedItem?.id, existing.id)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Reusable note"])
}
func testUpdateCollectionRenamesAssignedItemsAndColor() {
let settings = makeSettings()
let cacheService = makeCacheService()
@@ -2419,6 +2564,10 @@ final class ClipboardPanelViewModelTests: XCTestCase {
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.searchText = "draft"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
viewModel.clearSearch()
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
viewModel.updateSelectedText(to: "final launch note")
store.flushPersistenceForTesting()
@@ -2530,7 +2679,9 @@ final class ClipboardPanelViewModelTests: XCTestCase {
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.rotateSelectedImageClockwise()
let completion = expectation(description: "Rotate image off the main thread")
viewModel.rotateSelectedImageClockwise(completion: completion.fulfill)
wait(for: [completion], timeout: 2)
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Rotated image")
@@ -2597,7 +2748,9 @@ final class ClipboardPanelViewModelTests: XCTestCase {
)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.extractTextFromSelectedImage()
let completion = expectation(description: "Extract image text off the main thread")
viewModel.extractTextFromSelectedImage(completion: completion.fulfill)
wait(for: [completion], timeout: 2)
store.flushPersistenceForTesting()
XCTAssertEqual(extractionCount, 1)
@@ -2650,7 +2803,9 @@ final class ClipboardPanelViewModelTests: XCTestCase {
)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.extractTextFromSelectedImage()
let completion = expectation(description: "Finish empty image text extraction")
viewModel.extractTextFromSelectedImage(completion: completion.fulfill)
wait(for: [completion], timeout: 2)
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "No text found in image")
@@ -2737,10 +2892,17 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.statusMessage, "Copied")
var statusCallbacks: [String] = []
var captureStatusCallbackCount = 0
viewModel.onStatusMessageChanged = { statusCallbacks.append($0) }
viewModel.onCaptureStatusChanged = { captureStatusCallbackCount += 1 }
settings.setCaptureStatus(message: "Capture status updated while panel is open")
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertEqual(viewModel.statusMessage, "")
XCTAssertEqual(statusCallbacks, [""])
XCTAssertEqual(captureStatusCallbackCount, 1)
}
private func makeSettings() -> SettingsModel {

File diff suppressed because it is too large Load Diff

View File

@@ -84,27 +84,6 @@ final class SettingsModelTests: XCTestCase {
XCTAssertTrue(restored.hideFromScreenCapture)
}
func testCompactModePersistsAndNotifies() {
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let settings = SettingsModel(defaults: defaults)
var changes: [SettingsModel.Change] = []
settings.observe { changes.append($0) }
XCTAssertFalse(settings.compactMode)
settings.compactMode = true
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.compactMode))
XCTAssertEqual(changes, [.compactMode])
let restored = SettingsModel(defaults: defaults)
XCTAssertTrue(restored.compactMode)
}
func testPanelSidePersistsAndNotifies() {
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
@@ -115,17 +94,14 @@ final class SettingsModelTests: XCTestCase {
var changes: [SettingsModel.Change] = []
settings.observe { changes.append($0) }
XCTAssertEqual(settings.panelLayout, .vertical)
XCTAssertEqual(settings.panelSide, .right)
settings.panelSide = .left
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelLayout), ClipboardPanelLayout.vertical.rawValue)
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelSide), ClipboardPanelSide.left.rawValue)
XCTAssertEqual(changes, [.panelLayout])
XCTAssertEqual(changes, [.panelSide])
let restored = SettingsModel(defaults: defaults)
XCTAssertEqual(restored.panelLayout, .vertical)
XCTAssertEqual(restored.panelSide, .left)
}
@@ -178,27 +154,6 @@ final class SettingsModelTests: XCTestCase {
XCTAssertFalse(restored.iCloudSyncEnabled)
}
func testPanelShelfHeightPersistsAndNotifies() {
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let settings = SettingsModel(defaults: defaults)
var changes: [SettingsModel.Change] = []
settings.observe { changes.append($0) }
XCTAssertEqual(settings.panelShelfHeight, 0)
settings.panelShelfHeight = 620
XCTAssertEqual(defaults.double(forKey: SettingsModel.Keys.panelShelfHeight), 620)
XCTAssertEqual(changes, [.panelSizing])
let restored = SettingsModel(defaults: defaults)
XCTAssertEqual(restored.panelShelfHeight, 620)
}
func testPauseCaptureUntilPersistsAndCanBeCleared() {
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!

View File

@@ -20,10 +20,10 @@ final class SettingsWindowControllerTests: XCTestCase {
let (controller, _) = makeController()
XCTAssertTrue(controller.debugWindowStyleMask.contains(.resizable))
XCTAssertEqual(controller.debugWindowMinSize.width, 620, accuracy: 0.5)
XCTAssertEqual(controller.debugWindowMinSize.height, 560, accuracy: 0.5)
XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.width, 700)
XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.height, 600)
XCTAssertEqual(controller.debugWindowMinSize.width, 560, accuracy: 0.5)
XCTAssertEqual(controller.debugWindowMinSize.height, 440, accuracy: 0.5)
XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.width, 600)
XCTAssertGreaterThanOrEqual(controller.debugWindowContentSize.height, 500)
}
func testSettingsTabsUsePinnedScrollableDocuments() {
@@ -40,12 +40,12 @@ final class SettingsWindowControllerTests: XCTestCase {
}
}
func testSettingsDataTabKeepsExtraNativeHighlightPadding() {
func testSettingsTabsUseUnpaddedLabelsWithCustomSelector() {
let (controller, _) = makeController()
XCTAssertEqual(controller.debugSettingsTabLayoutMetrics.map { $0.label }, ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"])
XCTAssertEqual(controller.debugRawSettingsTabLabels.last?.clipboardTrimmed, "Data")
XCTAssertGreaterThan(controller.debugRawSettingsTabLabels.last?.count ?? 0, "Data".count)
XCTAssertEqual(controller.debugRawSettingsTabLabels.last, "Data")
}
func testSettingsTabsKeepContentAnchoredToTopLeadingCorner() {
@@ -64,7 +64,7 @@ final class SettingsWindowControllerTests: XCTestCase {
func testSettingsTabsAvoidHorizontalOverflowAtMinimumWindowSize() {
let (controller, _) = makeController()
controller.debugSetWindowContentSize(NSSize(width: 620, height: 560))
controller.debugSetWindowContentSize(NSSize(width: 560, height: 440))
let metrics = controller.debugSettingsTabLayoutAuditMetrics
@@ -75,6 +75,63 @@ final class SettingsWindowControllerTests: XCTestCase {
}
}
func testSettingsGeneralTabUsesCompactVerticalLayout() {
let (controller, _) = makeController()
let metrics = controller.debugSettingsTabContentPlacementMetrics
let general = try! XCTUnwrap(metrics.first { $0.label == "General" })
XCTAssertLessThan(general.contentBounds.maxY, 380)
}
func testShortcutModifiersExposeHumanAccessibilityNames() {
let (controller, _) = makeController()
XCTAssertEqual(
controller.debugOpenShortcutModifierAccessibilityLabels,
["Command modifier", "Option modifier", "Control modifier", "Shift modifier"]
)
XCTAssertEqual(
controller.debugOpenShortcutModifierAccessibilityHelps,
[
"Include or remove the Command key from this shortcut.",
"Include or remove the Option key from this shortcut.",
"Include or remove the Control key from this shortcut.",
"Include or remove the Shift key from this shortcut."
]
)
}
func testWritesSettingsVisualSnapshotWhenRequested() throws {
guard ProcessInfo.processInfo.environment["CLIPBORED_WRITE_SETTINGS_SNAPSHOT"] == "1" else {
throw XCTSkip("Set CLIPBORED_WRITE_SETTINGS_SNAPSHOT=1 to write a Settings snapshot.")
}
let (controller, _) = makeController()
controller.debugSelectSettingsTab(at: 5)
controller.debugPrepareWindowForSnapshot()
drainMainQueue()
let view = try XCTUnwrap(controller.debugSettingsContentView)
view.layoutSubtreeIfNeeded()
view.displayIfNeeded()
let output = URL(
fileURLWithPath: ProcessInfo.processInfo.environment["CLIPBORED_SETTINGS_SNAPSHOT_PATH"]
?? "build/visual-snapshots/settings-data.png"
)
try FileManager.default.createDirectory(
at: output.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
rep.size = view.bounds.size
view.cacheDisplay(in: view.bounds, to: rep)
let image = NSImage(size: view.bounds.size)
image.addRepresentation(rep)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(image.tiffRepresentation)))
let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
try png.write(to: output)
}
func testCloudSyncStatusIsCachedAcrossUnrelatedSettingsRefreshes() {
let cloudSyncService = CloudSyncProbe(
status: ClipboardCloudSyncStatus(

View File

@@ -36,24 +36,11 @@ final class ShortcutManagerTests: XCTestCase {
manager.stop()
}
func testRejectsUnsupportedSettingsShortcutBeforeRegistration() {
let manager = makeManager(
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: ShortcutBinding(key: "space", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
)
func testGlobalRegistrationExcludesLocalSettingsShortcut() {
let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut)
XCTAssertEqual(manager.start(), .unsupportedShortcut("⌘SPACE"))
manager.stop()
}
func testRejectsDuplicateShortcutBindingsBeforeRegistration() {
let manager = makeManager(
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: AppConfiguration.defaultOpenShortcut
)
XCTAssertEqual(manager.start(), .conflict(AppConfiguration.defaultOpenShortcut.displayText))
manager.stop()
XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut])
XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut))
}
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
@@ -63,15 +50,10 @@ final class ShortcutManagerTests: XCTestCase {
manager.stop()
}
private func makeManager(
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut
) -> ShortcutManager {
private func makeManager(openShortcut: ShortcutBinding) -> ShortcutManager {
ShortcutManager(
onOpenClipboardPanel: {},
onOpenSettings: {},
openShortcut: openShortcut,
settingsShortcut: settingsShortcut
openShortcut: openShortcut
)
}
}