Polish clipboard panel UX and interactions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)!
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user