Files
clipbored/tests/clipboredtests/ClipboardPanelViewModelTests.swift

552 lines
20 KiB
Swift
Raw Normal View History

2026-06-30 01:12:19 -07:00
import AppKit
import XCTest
@testable import ClipBored
final class ClipboardPanelViewModelTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDownWithError() throws {
for url in tempURLs {
try? FileManager.default.removeItem(at: url)
}
tempURLs.removeAll()
try super.tearDownWithError()
}
func testComputeVisibleItemsFiltersAndSortsByMode() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let sampleItems = makeSampleItems()
let filteredLinks = viewModel.computeVisibleItems(from: sampleItems, query: "https://", sortMode: .links)
XCTAssertEqual(filteredLinks.map(\.payload), ["https://apple.com"])
let recentByUse = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .mostUsed)
XCTAssertEqual(recentByUse.map(\.payload), ["two", "/tmp/report.pdf", "/tmp/voice.sound", "one", "https://apple.com", "four"])
let textOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .text)
XCTAssertEqual(textOnly.map(\.payload), ["four", "two", "one"])
let filesOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .files)
XCTAssertEqual(filesOnly.map(\.payload), ["/tmp/report.pdf"])
let audioOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .audio)
XCTAssertEqual(audioOnly.map(\.payload), ["/tmp/voice.sound"])
let pinnedOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .pinned)
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
}
func testSearchMatchesIndependentTokensCaseInsensitively() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let items = [
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "GitHub release token",
payload: "Copied from github.com",
payloadHash: hash("github-token"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil
),
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Unrelated note",
payload: "release notes",
payloadHash: hash("note"),
createdAt: Date(timeIntervalSince1970: 90),
lastUsedAt: Date(timeIntervalSince1970: 90),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
]
let result = viewModel.computeVisibleItems(from: items, query: "TOKEN github", sortMode: .mostRecent)
XCTAssertEqual(result.map(\.displayText), ["GitHub release token"])
}
func testCollectionsFilterSearchAndPersistSelection() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Release notes",
payload: "https://example.com/releases",
payloadHash: hash("https://example.com/releases"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil
)
let note = makeTextItem("Important meeting note", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(link)
store.upsert(note)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.assignSelected(to: " Client Work ")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.collectionNames, ["Client Work"])
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
XCTAssertEqual(viewModel.statusMessage, "Added to Client Work")
viewModel.selectCollection(named: "Client Work")
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/releases"])
viewModel.searchText = "release"
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
XCTAssertEqual(viewModel.visibleItems.map(\.displayText), ["Release notes"])
viewModel.searchText = "meeting"
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 0)
XCTAssertTrue(viewModel.visibleItems.isEmpty)
}
func testSearchTextRecomputesVisibleItemsImmediately() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("alpha note", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("needle note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["needle note"])
XCTAssertEqual(viewModel.selectedItem?.payload, "needle note")
}
func testSelectFirstItemSelectsFirstVisibleItem() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", 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.selectFirstItem()
XCTAssertEqual(viewModel.selectedItem?.payload, "newer")
}
func testSelectFirstItemPrefersLatestOnSubsequentUpdates() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", 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.selectFirstItem()
store.upsert(makeTextItem("latest", createdAt: Date(timeIntervalSince1970: 300)))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 3)
XCTAssertEqual(viewModel.selectedItem?.payload, "latest")
}
func testDuplicateRecopyMovesExistingClipToMostRecentFront() {
let settings = makeSettings()
settings.pruneDuplicates = true
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older duplicate text", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("new unique text", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "new unique text")
store.upsert(makeTextItem("older duplicate text", createdAt: Date(timeIntervalSince1970: 50)))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "older duplicate text")
}
func testPollThenSearchAndCopyFlow() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let payload = "clipbored-flow-test-\(UUID().uuidString)"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(payload, forType: .string))
monitor.pollNowAndWait()
waitForStoreCount(store, count: 1, matching: payload)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.searchText = payload
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.visibleItems.first?.payload, payload)
viewModel.copySelected()
XCTAssertEqual(NSPasteboard.general.string(forType: .string), payload)
XCTAssertEqual(viewModel.statusMessage, "Copied")
}
func testCopySelectedWritesTextToPasteboard() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("panel copy text", createdAt: Date(timeIntervalSince1970: 100))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload)
}
func testCopySelectedWritesURLToPasteboardTypes() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "https://example.com",
payload: "https://example.com",
payloadHash: hash("https://example.com"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload)
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), item.payload)
}
func testFailedCopyDoesNotMarkItemUsed() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 0)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Could not write item to clipboard.")
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 0)
XCTAssertEqual(store.items.first?.lastUsedAt, item.lastUsedAt)
}
func testFailedPasteDoesNotMarkItemUsed() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 2)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
var didRequestHide = false
viewModel.willPasteToTarget = { didRequestHide = true }
waitForVisibleItems(in: viewModel, count: 1)
viewModel.pasteSelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Could not write item to clipboard.")
XCTAssertFalse(didRequestHide)
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 2)
XCTAssertEqual(store.items.first?.lastUsedAt, item.lastUsedAt)
}
func testPasteWithoutTargetCopiesButDoesNotRequestPanelHide() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("manual paste fallback", createdAt: Date(timeIntervalSince1970: 10))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
var didRequestHide = false
viewModel.willPasteToTarget = { didRequestHide = true }
viewModel.targetApplicationProvider = { nil }
waitForVisibleItems(in: viewModel, count: 1)
viewModel.pasteSelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertFalse(didRequestHide)
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 1)
}
func testActionStatusIsNotOverwrittenUntilCaptureStatusChanges() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("status visibility", createdAt: Date(timeIntervalSince1970: 10))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
settings.setCaptureStatus(message: "Capture status updated while panel is open")
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertEqual(viewModel.statusMessage, "")
}
private func makeSettings() -> SettingsModel {
let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.testmodel.\(UUID().uuidString)")!)
settings.maxHistoryItems = 10
settings.includeImageTextInSearch = false
settings.pruneDuplicates = false
return settings
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
let cacheService = makeCacheService()
return makeStore(settings: settings, cacheService: cacheService)
}
private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore {
let tempURL = makeTempDirectory()
return ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: tempURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
)
}
private func makeCacheService() -> ClipboardCacheService {
ClipboardCacheService(baseURL: makeTempDirectory(), encryptionService: ClipboardEncryptionService(keyProvider: { nil }))
}
private func makeTempDirectory() -> URL {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
tempURLs.append(tempURL)
return tempURL
}
private func makeMissingFileItem(useCount: Int) -> ClipboardItem {
let date = Date(timeIntervalSince1970: 2_000)
let missingPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-missing-\(UUID().uuidString)")
.path
return ClipboardItem(
id: UUID(),
kind: .file,
displayText: "Missing file",
payload: missingPath,
payloadHash: hash(missingPath),
createdAt: date,
lastUsedAt: date,
useCount: useCount,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .text,
displayText: text,
payload: text,
payloadHash: hash(text),
createdAt: createdAt,
lastUsedAt: createdAt,
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func waitForStoreCount(
_ store: ClipboardStore,
count: Int,
matching payload: String,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1)
while store.items.filter({ $0.payload == payload }).count != count && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
XCTAssertEqual(store.items.filter({ $0.payload == payload }).count, count, file: file, line: line)
}
private func waitForVisibleItems(
in viewModel: ClipboardPanelViewModel,
count: Int,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1)
while viewModel.visibleItems.count != count && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
XCTAssertEqual(viewModel.visibleItems.count, count, file: file, line: line)
}
private func makeSampleItems() -> [ClipboardItem] {
[
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Project notes",
payload: "one",
payloadHash: hash("one"),
createdAt: Date(timeIntervalSince1970: 1000),
lastUsedAt: Date(timeIntervalSince1970: 1000),
useCount: 2,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: true
),
ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Two",
payload: "two",
payloadHash: hash("two"),
createdAt: Date(timeIntervalSince1970: 1100),
lastUsedAt: Date(timeIntervalSince1970: 1080),
useCount: 4,
sourceApp: "Mail",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Apple",
payload: "https://apple.com",
payloadHash: hash("https://apple.com"),
createdAt: Date(timeIntervalSince1970: 1030),
lastUsedAt: Date(timeIntervalSince1970: 1050),
useCount: 1,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .file,
displayText: "report.pdf",
payload: "/tmp/report.pdf",
payloadHash: hash("/tmp/report.pdf"),
createdAt: Date(timeIntervalSince1970: 1060),
lastUsedAt: Date(timeIntervalSince1970: 1070),
useCount: 3,
sourceApp: "Finder",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Voice memo",
payload: "/tmp/voice.sound",
payloadHash: hash("/tmp/voice.sound"),
createdAt: Date(timeIntervalSince1970: 1040),
lastUsedAt: Date(timeIntervalSince1970: 1060),
useCount: 2,
sourceApp: "Voice Memos",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Four",
payload: "four",
payloadHash: hash("four"),
createdAt: Date(timeIntervalSince1970: 1200),
lastUsedAt: Date(timeIntervalSince1970: 1200),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: true
)
]
}
private func hash(_ value: String) -> String {
value
}
}