WIP
This commit is contained in:
100
tests/clipboredtests/AppDelegateTests.swift
Normal file
100
tests/clipboredtests/AppDelegateTests.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class AppDelegateTests: XCTestCase {
|
||||
func testStatusItemMenuRoutingSeparatesLeftAndRightClick() {
|
||||
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: []))
|
||||
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: []))
|
||||
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: .control))
|
||||
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .otherMouseUp, modifierFlags: []))
|
||||
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseDragged, modifierFlags: .control))
|
||||
}
|
||||
|
||||
func testStatusMenuIncludesStateRowsAndBoundedActions() {
|
||||
let settingsTitle = "Settings\u{2026}"
|
||||
let presentation = AppDelegate.statusMenuPresentation(
|
||||
historyCount: 42,
|
||||
isCapturePaused: false,
|
||||
captureStatus: "Captured text from Safari.",
|
||||
pasteStatus: "",
|
||||
shortcutStatus: "",
|
||||
accessibilityStatus: "",
|
||||
launchAtLoginStatus: ""
|
||||
)
|
||||
let menu = AppDelegate.makeStatusMenu(
|
||||
presentation: presentation,
|
||||
isCapturePaused: false,
|
||||
openShortcut: AppConfiguration.defaultOpenShortcut,
|
||||
settingsShortcut: AppConfiguration.defaultSettingsShortcut,
|
||||
target: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
menu.items.map { $0.isSeparatorItem ? "-" : $0.title },
|
||||
[
|
||||
"ClipBored",
|
||||
"Capture Running - 42 clips",
|
||||
"Captured text from Safari.",
|
||||
"-",
|
||||
"Show Clipboard",
|
||||
settingsTitle,
|
||||
"-",
|
||||
"Pause Capture",
|
||||
"-",
|
||||
"Quit ClipBored"
|
||||
]
|
||||
)
|
||||
|
||||
let showClipboard = menu.items.first { $0.title == "Show Clipboard" }
|
||||
XCTAssertEqual(showClipboard?.keyEquivalent, "v")
|
||||
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.option) == true)
|
||||
|
||||
let settings = menu.items.first { $0.title == settingsTitle }
|
||||
XCTAssertEqual(settings?.keyEquivalent, ",")
|
||||
XCTAssertTrue(settings?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
}
|
||||
|
||||
func testStatusMenuPausedStateTakesPriorityOverOlderCaptureStatus() {
|
||||
let presentation = AppDelegate.statusMenuPresentation(
|
||||
historyCount: 1,
|
||||
isCapturePaused: true,
|
||||
captureStatus: "Captured link from Safari.",
|
||||
pasteStatus: "Copied",
|
||||
shortcutStatus: "",
|
||||
accessibilityStatus: "",
|
||||
launchAtLoginStatus: ""
|
||||
)
|
||||
let menu = AppDelegate.makeStatusMenu(
|
||||
presentation: presentation,
|
||||
isCapturePaused: true,
|
||||
openShortcut: AppConfiguration.defaultOpenShortcut,
|
||||
settingsShortcut: AppConfiguration.defaultSettingsShortcut,
|
||||
target: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip")
|
||||
XCTAssertEqual(presentation.detail, "Capture is paused.")
|
||||
XCTAssertEqual(menu.items.first { $0.title == "Resume Capture" }?.state, .on)
|
||||
XCTAssertNil(menu.items.first { $0.title == "Pause Capture" })
|
||||
}
|
||||
|
||||
func testStatusMenuPresentationTruncatesLongStatusText() {
|
||||
let presentation = AppDelegate.statusMenuPresentation(
|
||||
historyCount: 2000,
|
||||
isCapturePaused: false,
|
||||
captureStatus: "Skipped:\n" + String(repeating: "A very long ignored source application name ", count: 4),
|
||||
pasteStatus: "",
|
||||
shortcutStatus: "",
|
||||
accessibilityStatus: "",
|
||||
launchAtLoginStatus: ""
|
||||
)
|
||||
|
||||
XCTAssertEqual(presentation.summary, "Capture Running - 2000 clips")
|
||||
XCTAssertNotNil(presentation.detail)
|
||||
XCTAssertLessThanOrEqual(presentation.detail?.count ?? 0, 68)
|
||||
XCTAssertTrue(presentation.detail?.hasSuffix("...") == true)
|
||||
XCTAssertFalse(presentation.detail?.contains("\n") == true)
|
||||
}
|
||||
}
|
||||
319
tests/clipboredtests/ClipboardCacheServiceTests.swift
Normal file
319
tests/clipboredtests/ClipboardCacheServiceTests.swift
Normal file
@@ -0,0 +1,319 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardCacheServiceTests: XCTestCase {
|
||||
private var tempURLs: [URL] = []
|
||||
|
||||
override func tearDown() {
|
||||
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||
tempURLs.removeAll()
|
||||
try? FileManager.default.removeItem(at: temporaryPreviewRoot())
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testPurgeRemovesImageCacheFilesOverByteLimit() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||
let first = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemRed), id: UUID()))
|
||||
let second = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: first.full))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: first.thumb))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: second.full))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: second.thumb))
|
||||
|
||||
cacheService.purgeIfNeeded(maxBytes: 1)
|
||||
cacheService.flushForTesting()
|
||||
|
||||
let remaining = try imageCacheFileURLs(in: baseURL)
|
||||
XCTAssertTrue(remaining.isEmpty)
|
||||
}
|
||||
|
||||
func testClearCacheRemovesOnlyImageCacheFiles() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||
let image = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemGreen), id: UUID()))
|
||||
let pdfPath = try XCTUnwrap(cacheService.cachePDF(Data("%PDF-1.4\n%%EOF".utf8), id: UUID()))
|
||||
let pdfItem = pdfItem(path: pdfPath)
|
||||
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: pdfItem))
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: image.full))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: image.thumb))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: pdfPath))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: previewURL.path))
|
||||
|
||||
cacheService.clearCache()
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: pdfPath))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: previewURL.path))
|
||||
}
|
||||
|
||||
func testImageCacheFilesAreEncryptedAndLoadable() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let image = makeImage(color: .systemPurple)
|
||||
|
||||
let paths = try XCTUnwrap(cacheService.cacheImage(image, id: UUID()))
|
||||
let rawFull = try Data(contentsOf: URL(fileURLWithPath: paths.full))
|
||||
let rawThumb = try Data(contentsOf: URL(fileURLWithPath: paths.thumb))
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawFull))
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawThumb))
|
||||
XCTAssertNil(NSImage(data: rawFull))
|
||||
XCTAssertNotNil(cacheService.image(for: paths.full))
|
||||
XCTAssertNotNil(cacheService.image(for: paths.thumb))
|
||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: paths.full)), 0o600)
|
||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: paths.thumb)), 0o600)
|
||||
}
|
||||
|
||||
func testPreviewThumbnailUsesExistingFilePreview() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||
let imageURL = baseURL.appendingPathComponent("Copied Image.png")
|
||||
let imageData = try XCTUnwrap(makeImage(color: .systemOrange).pngData())
|
||||
try imageData.write(to: imageURL)
|
||||
|
||||
let item = fileItem(path: imageURL.path)
|
||||
|
||||
XCTAssertNotNil(cacheService.previewThumbnail(for: item))
|
||||
}
|
||||
|
||||
func testPreviewThumbnailUsesManagedPDFPreviewFallback() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let pdfData = try makePDFData()
|
||||
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
|
||||
|
||||
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
|
||||
}
|
||||
|
||||
func testPDFCacheFilesAreEncryptedAndReadable() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
|
||||
|
||||
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
|
||||
let rawPDF = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawPDF))
|
||||
XCTAssertNotEqual(rawPDF, pdfData)
|
||||
XCTAssertEqual(cacheService.data(for: path), pdfData)
|
||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
||||
}
|
||||
|
||||
func testAudioCacheFilesAreEncryptedAndReadable() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let audioData = Data([0, 1, 2, 3, 5, 8, 13])
|
||||
|
||||
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
|
||||
let rawAudio = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawAudio))
|
||||
XCTAssertNotEqual(rawAudio, audioData)
|
||||
XCTAssertEqual(cacheService.data(for: path), audioData)
|
||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
||||
}
|
||||
|
||||
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let rtfData = Data("{\\rtf1\\ansi ClipBored}".utf8)
|
||||
|
||||
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
|
||||
let rawRTF = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawRTF))
|
||||
XCTAssertNotEqual(rawRTF, rtfData)
|
||||
XCTAssertEqual(cacheService.data(for: path), rtfData)
|
||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
||||
}
|
||||
|
||||
func testLegacyManagedSidecarIsEncryptedAfterRead() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let attachmentDirectory = baseURL.appendingPathComponent("attachments", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
||||
let url = attachmentDirectory.appendingPathComponent("\(UUID().uuidString).pdf")
|
||||
let pdfData = Data("%PDF-1.4\nlegacy\n%%EOF".utf8)
|
||||
try pdfData.write(to: url)
|
||||
|
||||
XCTAssertEqual(cacheService.data(for: url.path), pdfData)
|
||||
|
||||
let migrated = try Data(contentsOf: url)
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(migrated))
|
||||
XCTAssertFalse(String(decoding: migrated, as: UTF8.self).contains("legacy"))
|
||||
}
|
||||
|
||||
func testTemporaryReadableURLWritesPrivateCopyAndCleanupRemovesIt() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let pdfData = Data("%PDF-1.4\ntemporary preview\n%%EOF".utf8)
|
||||
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
|
||||
|
||||
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: pdfItem(path: path)))
|
||||
|
||||
XCTAssertEqual(try Data(contentsOf: previewURL), pdfData)
|
||||
XCTAssertEqual(try posixPermissions(previewURL.deletingLastPathComponent()), 0o700)
|
||||
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||
|
||||
cacheService.clearTemporaryPreviews(wait: true)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: previewURL.path))
|
||||
}
|
||||
|
||||
func testTemporaryReadableURLWorksForAudio() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let audioData = Data([9, 8, 7, 6])
|
||||
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
|
||||
|
||||
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: audioItem(path: path)))
|
||||
|
||||
XCTAssertEqual(try Data(contentsOf: previewURL), audioData)
|
||||
XCTAssertEqual(previewURL.pathExtension, "sound")
|
||||
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||
}
|
||||
|
||||
func testTemporaryReadableURLWorksForRichText() throws {
|
||||
let baseURL = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||
let rtfData = Data("{\\rtf1\\ansi Temporary Rich Text}".utf8)
|
||||
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
|
||||
|
||||
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: richTextItem(path: path)))
|
||||
|
||||
XCTAssertEqual(try Data(contentsOf: previewURL), rtfData)
|
||||
XCTAssertEqual(previewURL.pathExtension, "rtf")
|
||||
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||
}
|
||||
|
||||
private func makeTempDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
tempURLs.append(url)
|
||||
return url
|
||||
}
|
||||
|
||||
private func imageCacheFileURLs(in baseURL: URL) throws -> [URL] {
|
||||
let imageDirectory = baseURL.appendingPathComponent("images", isDirectory: true)
|
||||
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
|
||||
}
|
||||
|
||||
private func pdfItem(path: String) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .pdf,
|
||||
displayText: "PDF",
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func fileItem(path: String) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
displayText: "File",
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func audioItem(path: String) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .audio,
|
||||
displayText: "Audio",
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func richTextItem(path: String) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .richText,
|
||||
displayText: "Rich Text",
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func temporaryPreviewRoot() -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||
}
|
||||
|
||||
private func makeImage(color: NSColor) -> NSImage {
|
||||
let size = NSSize(width: 24, height: 24)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
color.setFill()
|
||||
NSRect(origin: .zero, size: size).fill()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
|
||||
private func makePDFData() throws -> Data {
|
||||
let data = NSMutableData()
|
||||
guard let consumer = CGDataConsumer(data: data as CFMutableData) else {
|
||||
throw NSError(domain: "ClipBoredTests", code: 1)
|
||||
}
|
||||
var box = CGRect(x: 0, y: 0, width: 160, height: 120)
|
||||
guard let context = CGContext(consumer: consumer, mediaBox: &box, nil) else {
|
||||
throw NSError(domain: "ClipBoredTests", code: 2)
|
||||
}
|
||||
|
||||
context.beginPDFPage(nil)
|
||||
context.setFillColor(NSColor.systemOrange.cgColor)
|
||||
context.fill(CGRect(x: 0, y: 0, width: 160, height: 120))
|
||||
context.setFillColor(NSColor.systemBlue.cgColor)
|
||||
context.fillEllipse(in: CGRect(x: 45, y: 30, width: 70, height: 60))
|
||||
context.endPDFPage()
|
||||
context.closePDF()
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func posixPermissions(_ url: URL) throws -> Int {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777
|
||||
}
|
||||
|
||||
private func noOpEncryptionService() -> ClipboardEncryptionService {
|
||||
ClipboardEncryptionService(keyProvider: { nil })
|
||||
}
|
||||
|
||||
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
|
||||
let keyData = Data(repeating: byte, count: 32)
|
||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||
}
|
||||
}
|
||||
73
tests/clipboredtests/ClipboardEncryptionServiceTests.swift
Normal file
73
tests/clipboredtests/ClipboardEncryptionServiceTests.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardEncryptionServiceTests: XCTestCase {
|
||||
func testProtectAndUnprotectRoundTrip() throws {
|
||||
let service = makeService(byte: 7)
|
||||
let value = "secret clipboard value \(UUID().uuidString)"
|
||||
|
||||
let protected = try XCTUnwrap(service.protect(value))
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(protected))
|
||||
XCTAssertFalse(protected.contains(value))
|
||||
XCTAssertEqual(service.unprotect(protected), value)
|
||||
}
|
||||
|
||||
func testUnprotectLeavesPlaintextValuesUntouched() {
|
||||
let service = makeService(byte: 7)
|
||||
let value = "plain clipboard value"
|
||||
|
||||
XCTAssertEqual(service.unprotect(value), value)
|
||||
}
|
||||
|
||||
func testMarkerLookingPlaintextIsStillEncryptable() throws {
|
||||
let service = makeService(byte: 7)
|
||||
let value = ClipboardEncryptionService.marker + "not encrypted user text"
|
||||
|
||||
let protected = try XCTUnwrap(service.protect(value))
|
||||
|
||||
XCTAssertNotEqual(protected, value)
|
||||
XCTAssertEqual(service.unprotect(protected), value)
|
||||
XCTAssertEqual(service.unprotect(value), value)
|
||||
}
|
||||
|
||||
func testWrongKeyCannotDecryptProtectedValue() throws {
|
||||
let service = makeService(byte: 7)
|
||||
let wrongService = makeService(byte: 9)
|
||||
let protected = try XCTUnwrap(service.protect("keyed secret"))
|
||||
|
||||
XCTAssertNil(wrongService.unprotect(protected))
|
||||
}
|
||||
|
||||
func testProtectDataAndUnprotectDataRoundTrip() throws {
|
||||
let service = makeService(byte: 7)
|
||||
let data = Data((0..<128).map { UInt8($0) })
|
||||
|
||||
let protected = service.protectData(data)
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(protected))
|
||||
XCTAssertNotEqual(protected, data)
|
||||
XCTAssertEqual(service.unprotectData(protected), data)
|
||||
}
|
||||
|
||||
func testWrongKeyCannotDecryptProtectedData() {
|
||||
let service = makeService(byte: 7)
|
||||
let wrongService = makeService(byte: 9)
|
||||
let protected = service.protectData(Data("binary secret".utf8))
|
||||
|
||||
XCTAssertNil(wrongService.unprotectData(protected))
|
||||
}
|
||||
|
||||
func testProtectFallsBackToPlaintextWhenKeyIsUnavailable() {
|
||||
let service = ClipboardEncryptionService(keyProvider: { nil })
|
||||
|
||||
XCTAssertEqual(service.protect("available only in memory"), "available only in memory")
|
||||
XCTAssertEqual(service.protectData(Data("available only in memory".utf8)), Data("available only in memory".utf8))
|
||||
}
|
||||
|
||||
private func makeService(byte: UInt8) -> ClipboardEncryptionService {
|
||||
let keyData = Data(repeating: byte, count: 32)
|
||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||
}
|
||||
}
|
||||
722
tests/clipboredtests/ClipboardMonitorServiceTests.swift
Normal file
722
tests/clipboredtests/ClipboardMonitorServiceTests.swift
Normal file
@@ -0,0 +1,722 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import AppKit
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardMonitorServiceTests: XCTestCase {
|
||||
private var tempURLs: [URL] = []
|
||||
private var suiteNames: [String] = []
|
||||
|
||||
override func tearDown() {
|
||||
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||
tempURLs.removeAll()
|
||||
suiteNames.forEach {
|
||||
UserDefaults(suiteName: $0)?.removePersistentDomain(forName: $0)
|
||||
}
|
||||
suiteNames.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testClampedIntervalEnforcesResponsiveMinimum() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pollProfile = .responsive
|
||||
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: makeStore(settings: settings),
|
||||
cacheService: ClipboardCacheService(),
|
||||
settings: settings
|
||||
)
|
||||
|
||||
XCTAssertEqual(monitor.clampedInterval(0.03), AppConfiguration.minResponsiveActiveInterval)
|
||||
XCTAssertEqual(monitor.clampedInterval(0.05), AppConfiguration.minResponsiveActiveInterval)
|
||||
XCTAssertEqual(monitor.clampedInterval(0.075), AppConfiguration.minResponsiveActiveInterval)
|
||||
XCTAssertEqual(monitor.clampedInterval(0.2), 0.2)
|
||||
}
|
||||
|
||||
func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pollProfile = .balanced
|
||||
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: makeStore(settings: settings),
|
||||
cacheService: ClipboardCacheService(),
|
||||
settings: settings
|
||||
)
|
||||
|
||||
XCTAssertEqual(monitor.clampedInterval(settings.pollProfile.activeInterval), settings.pollProfile.activeInterval)
|
||||
XCTAssertGreaterThanOrEqual(monitor.clampedInterval(settings.pollProfile.idleInterval), settings.pollProfile.idleInterval)
|
||||
}
|
||||
|
||||
func testPollProfileChangeEmitsDedicatedNotification() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
var sawPollProfileChange = false
|
||||
|
||||
settings.observe { change in
|
||||
if case .pollProfile = change {
|
||||
sawPollProfileChange = true
|
||||
}
|
||||
}
|
||||
|
||||
settings.pollProfile = .responsive
|
||||
|
||||
XCTAssertTrue(sawPollProfileChange)
|
||||
}
|
||||
|
||||
func testSetPausedReschedulesWithCurrentPollingProfile() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pollProfile = .battery
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
|
||||
monitor.setPaused(false)
|
||||
XCTAssertEqual(monitor.scheduledIntervalForTesting, settings.pollProfile.idleInterval)
|
||||
|
||||
settings.pollProfile = .responsive
|
||||
monitor.setPaused(false)
|
||||
XCTAssertEqual(monitor.scheduledIntervalForTesting, settings.pollProfile.idleInterval)
|
||||
|
||||
monitor.stop()
|
||||
}
|
||||
|
||||
func testPollNowCapturesCopiedTextOnce() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pruneDuplicates = false
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
settings: settings
|
||||
)
|
||||
let text = "ClipBored monitor smoke \(UUID().uuidString)"
|
||||
|
||||
let captured = expectation(description: "copied text captured")
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.payload == text }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(text, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
|
||||
}
|
||||
|
||||
func testPollNowIgnoresClipBoredPasteboardWrites() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Internal copy",
|
||||
payload: "Internal copy \(UUID().uuidString)",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(PasteActionService().copy(item), .copied)
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
}
|
||||
|
||||
func testPollNowCapturesPDFAsRestorableAttachment() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
|
||||
let captured = expectation(description: "PDF captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .pdf }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(pdfData, forType: .pdf))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .pdf }))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
|
||||
XCTAssertEqual(cacheService.data(for: item.payload), pdfData)
|
||||
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
|
||||
}
|
||||
|
||||
func testPollNowCapturesAudioAsRestorableAttachment() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let audioData = Data([1, 2, 3, 4, 8, 16])
|
||||
let captured = expectation(description: "audio captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .audio }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(audioData, forType: .sound))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .audio }))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
|
||||
XCTAssertEqual(cacheService.data(for: item.payload), audioData)
|
||||
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
||||
}
|
||||
|
||||
func testPollNowCapturesFileReference() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let fileURL = try makeTempFile(contents: "file reference")
|
||||
let captured = expectation(description: "file reference captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .file && $0.payload == fileURL.path }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .file)
|
||||
XCTAssertEqual(store.items.first?.payload, fileURL.path)
|
||||
}
|
||||
|
||||
func testPollNowCapturesMultipleFileReferencesAsOneRestorableItem() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let firstURL = try makeTempFile(contents: "first file reference")
|
||||
let secondURL = try makeTempFile(contents: "second file reference")
|
||||
let payload = FilePayload.payload(from: [firstURL, secondURL])
|
||||
let captured = expectation(description: "multiple file references captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .file && $0.payload == payload }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([firstURL as NSURL, secondURL as NSURL]))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(item.kind, .file)
|
||||
XCTAssertEqual(item.displayText, "2 files")
|
||||
XCTAssertEqual(item.payload, payload)
|
||||
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
|
||||
XCTAssertEqual(objects?.map(\.standardizedFileURL), [firstURL.standardizedFileURL, secondURL.standardizedFileURL])
|
||||
}
|
||||
|
||||
func testPollNowPrefersFileReferenceOverStringFallback() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let fileURL = try makeTempFile(contents: "file reference with fallback")
|
||||
let captured = expectation(description: "file reference captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .file && $0.payload == fileURL.path }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
|
||||
XCTAssertTrue(pasteboard.setString(fileURL.path, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .file)
|
||||
XCTAssertEqual(store.items.first?.payload, fileURL.path)
|
||||
}
|
||||
|
||||
func testPollNowCapturesBareURLAsLink() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let url = "https://example.com/releases"
|
||||
let captured = expectation(description: "URL captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .url && $0.payload == url }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .url)
|
||||
XCTAssertEqual(store.items.first?.payload, url)
|
||||
}
|
||||
|
||||
func testPollNowUsesPasteboardURLNameAsLinkTitle() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let url = "https://example.com/releases"
|
||||
let title = "Release notes"
|
||||
let captured = expectation(description: "URL with title captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .url && $0.payload == url && $0.displayText == title }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
|
||||
XCTAssertTrue(pasteboard.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")))
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .url)
|
||||
XCTAssertEqual(store.items.first?.displayText, title)
|
||||
XCTAssertEqual(store.items.first?.payload, url)
|
||||
}
|
||||
|
||||
func testPollNowUsesHTMLAnchorTextAsLinkTitle() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let url = "https://example.com/releases"
|
||||
let title = "Read the release notes"
|
||||
let html = "<a href=\"\(url)\">\(title)</a>"
|
||||
let captured = expectation(description: "URL with HTML title captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .url && $0.payload == url && $0.displayText == title }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
|
||||
XCTAssertTrue(pasteboard.setString(html, forType: .html))
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .url)
|
||||
XCTAssertEqual(store.items.first?.displayText, title)
|
||||
XCTAssertEqual(store.items.first?.payload, url)
|
||||
}
|
||||
|
||||
func testPollNowCapturesURLWithLocalImagePreviewAsLink() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let url = "https://example.com/lookbook"
|
||||
let title = "Lookbook"
|
||||
let preview = makeImage(color: .systemTeal)
|
||||
let captured = expectation(description: "URL with local preview captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if let item = items.first, item.kind == .url, item.payload == url, item.thumbnailPath != nil {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
|
||||
XCTAssertTrue(pasteboard.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")))
|
||||
XCTAssertTrue(pasteboard.setData(try XCTUnwrap(preview.tiffRepresentation), forType: .tiff))
|
||||
XCTAssertTrue(pasteboard.setString(url, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
cacheService.flushForTesting()
|
||||
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(item.kind, .url)
|
||||
XCTAssertEqual(item.displayText, title)
|
||||
XCTAssertNotNil(item.imagePath)
|
||||
XCTAssertNotNil(item.thumbnailPath)
|
||||
XCTAssertNotNil(cacheService.previewThumbnail(for: item))
|
||||
}
|
||||
|
||||
func testPollNowCapturesImageWithRecognizedTextWhenSearchIsEnabled() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.includeImageTextInSearch = true
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
settings: settings,
|
||||
imageTextExtractor: { _ in " Receipt total $42\nOrder 1001 " }
|
||||
)
|
||||
let image = makeImage(color: .systemOrange)
|
||||
let captured = expectation(description: "image with recognized text captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .image && $0.ocrText == "Receipt total $42 Order 1001" }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([image]))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(item.kind, .image)
|
||||
XCTAssertEqual(item.ocrText, "Receipt total $42 Order 1001")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
|
||||
}
|
||||
|
||||
func testPollNowSkipsImageTextExtractionWhenSearchIsDisabled() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.includeImageTextInSearch = false
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
var extractionCount = 0
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
settings: settings,
|
||||
imageTextExtractor: { _ in
|
||||
extractionCount += 1
|
||||
return "Should not be captured"
|
||||
}
|
||||
)
|
||||
let image = makeImage(color: .systemPurple)
|
||||
let captured = expectation(description: "image captured without recognized text")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .image }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([image]))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(item.kind, .image)
|
||||
XCTAssertNil(item.ocrText)
|
||||
XCTAssertEqual(extractionCount, 0)
|
||||
}
|
||||
|
||||
func testIgnoredFileSettingDoesNotBlockWebURLObjects() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.file.rawValue]
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let url = "https://example.com/releases"
|
||||
let captured = expectation(description: "web URL captured when files are ignored")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .url && $0.payload == url }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([NSURL(string: url)!]))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .url)
|
||||
XCTAssertEqual(store.items.first?.payload, url)
|
||||
}
|
||||
|
||||
func testPollNowKeepsTextContainingURLAsText() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let text = "Review https://example.com/releases before shipping"
|
||||
let captured = expectation(description: "text with URL captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .text && $0.payload == text }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(text, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .text)
|
||||
XCTAssertEqual(store.items.first?.payload, text)
|
||||
}
|
||||
|
||||
func testPollNowPrefersRichTextOverPlainStringFallback() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let text = "Rich clipboard text"
|
||||
let attributed = NSAttributedString(
|
||||
string: text,
|
||||
attributes: [.font: NSFont.boldSystemFont(ofSize: 13)]
|
||||
)
|
||||
let rtfData = try XCTUnwrap(
|
||||
attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:])
|
||||
)
|
||||
let captured = expectation(description: "rich text captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .richText && $0.displayText == text }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(rtfData, forType: .rtf))
|
||||
XCTAssertTrue(pasteboard.setString(text, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(store.items.first?.kind, .richText)
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(item.displayText, text)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
|
||||
XCTAssertEqual(cacheService.data(for: item.payload), rtfData)
|
||||
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .rtf), rtfData)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), text)
|
||||
}
|
||||
|
||||
func testPollNowCapturesHTMLClipboardDataAsRestorableRichText() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let text = "Styled HTML clipboard text"
|
||||
let html = """
|
||||
<span style="font-weight: 700; color: #0a84ff;">Styled HTML</span> clipboard text
|
||||
"""
|
||||
let htmlData = Data(html.utf8)
|
||||
let captured = expectation(description: "HTML rich text captured")
|
||||
|
||||
store.observeItems { items in
|
||||
if items.contains(where: { $0.kind == .richText && $0.displayText == text }) {
|
||||
captured.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(htmlData, forType: .html))
|
||||
XCTAssertTrue(pasteboard.setString(text, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [captured], timeout: 1.0)
|
||||
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(item.kind, .richText)
|
||||
XCTAssertEqual(item.displayText, text)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
|
||||
let cachedRTF = try XCTUnwrap(cacheService.data(for: item.payload))
|
||||
XCTAssertNotNil(NSAttributedString(rtf: cachedRTF, documentAttributes: nil))
|
||||
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||
XCTAssertNotNil(NSPasteboard.general.data(forType: .rtf))
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), text)
|
||||
}
|
||||
|
||||
func testIgnoredImageKindDoesNotWriteCacheFiles() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.image.rawValue]
|
||||
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let image = makeImage(color: .systemOrange)
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.writeObjects([image]))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
|
||||
}
|
||||
|
||||
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
|
||||
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(Data("%PDF-1.4\n%%EOF".utf8), forType: .pdf))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
|
||||
XCTAssertEqual(settings.captureStatusMessage, "Skipped: PDF items are ignored in capture settings.")
|
||||
}
|
||||
|
||||
func testIgnoredAudioKindDoesNotWriteAttachmentFiles() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.audio.rawValue]
|
||||
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(Data([3, 1, 4, 1, 5]), forType: .sound))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
|
||||
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.")
|
||||
}
|
||||
|
||||
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
|
||||
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
|
||||
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
let text = "Ignored HTML clipboard text"
|
||||
let html = "<strong>Ignored HTML</strong> clipboard text"
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setData(Data(html.utf8), forType: .html))
|
||||
XCTAssertTrue(pasteboard.setString(text, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
|
||||
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Rich Text items are ignored in capture settings.")
|
||||
}
|
||||
|
||||
private func makeTestDefaults() -> UserDefaults {
|
||||
let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)"
|
||||
suiteNames.append(suiteName)
|
||||
return UserDefaults(suiteName: suiteName)!
|
||||
}
|
||||
|
||||
private func makeStore(settings: SettingsModel) -> ClipboardStore {
|
||||
makeStoreAndCache(settings: settings).store
|
||||
}
|
||||
|
||||
private func makeStoreAndCache(settings: SettingsModel) -> (store: ClipboardStore, cacheService: ClipboardCacheService) {
|
||||
let result = makeStoreCacheAndBaseURL(settings: settings)
|
||||
return (result.store, result.cacheService)
|
||||
}
|
||||
|
||||
private func makeStoreCacheAndBaseURL(settings: SettingsModel) -> (store: ClipboardStore, cacheService: ClipboardCacheService, baseURL: URL) {
|
||||
let baseURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
tempURLs.append(baseURL)
|
||||
let cacheService = ClipboardCacheService(
|
||||
baseURL: baseURL,
|
||||
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
|
||||
)
|
||||
return (
|
||||
ClipboardStore(
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
baseURL: baseURL,
|
||||
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
|
||||
),
|
||||
cacheService,
|
||||
baseURL
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTempFile(contents: String) throws -> URL {
|
||||
let baseURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
tempURLs.append(baseURL)
|
||||
let fileURL = baseURL.appendingPathComponent("payload.txt")
|
||||
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
return fileURL
|
||||
}
|
||||
|
||||
private func imageCacheFileURLs(in baseURL: URL) throws -> [URL] {
|
||||
let imageDirectory = baseURL.appendingPathComponent("images", isDirectory: true)
|
||||
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
|
||||
}
|
||||
|
||||
private func attachmentFileURLs(in baseURL: URL) throws -> [URL] {
|
||||
let attachmentDirectory = baseURL.appendingPathComponent("attachments", isDirectory: true)
|
||||
return try FileManager.default.contentsOfDirectory(at: attachmentDirectory, includingPropertiesForKeys: nil)
|
||||
}
|
||||
|
||||
private func makeImage(color: NSColor) -> NSImage {
|
||||
let size = NSSize(width: 24, height: 24)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
color.setFill()
|
||||
NSRect(origin: .zero, size: size).fill()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
}
|
||||
147
tests/clipboredtests/ClipboardPanelControllerTests.swift
Normal file
147
tests/clipboredtests/ClipboardPanelControllerTests.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardPanelControllerTests: XCTestCase {
|
||||
func testPanelFrameUsesFullWidthBottomShelf() {
|
||||
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, screenFrame.minX)
|
||||
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, 408)
|
||||
XCTAssertLessThan(frames.hidden.maxY, screenFrame.minY)
|
||||
}
|
||||
|
||||
func testPanelFrameUsesVisibleFrameAroundDock() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, 408)
|
||||
}
|
||||
|
||||
func testPanelFrameSitsAboveVisibleBottomDock() {
|
||||
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)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, 408)
|
||||
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
|
||||
}
|
||||
|
||||
func testPanelFrameClampsTallDisplaysToShelfMaximum() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 3008, height: 2000)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.width, 3008)
|
||||
XCTAssertEqual(frames.shown.height, 430)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
}
|
||||
|
||||
func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() {
|
||||
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)
|
||||
|
||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, visibleFrame.height)
|
||||
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.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)
|
||||
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
for _ in 0..<50 {
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
XCTAssertEqual(frames.shown, first.shown)
|
||||
XCTAssertEqual(frames.hidden, first.hidden)
|
||||
XCTAssertEqual(frames.hidden.maxY, frames.shown.minY - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
|
||||
let profile = ClipboardPanelController.animationProfile
|
||||
|
||||
XCTAssertEqual(profile.showDuration, 0.16)
|
||||
XCTAssertEqual(profile.hideDuration, 0.12)
|
||||
XCTAssertEqual(profile.reflowDuration, 0.10)
|
||||
XCTAssertLessThanOrEqual(profile.showDuration * 60, 10)
|
||||
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 8)
|
||||
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 6)
|
||||
XCTAssertEqual(profile.easing, .easeInEaseOut)
|
||||
}
|
||||
|
||||
func testReflowPlanMovesOpenPanelAboveNewBottomDockVisibleFrame() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 112, width: 1512, height: 845)
|
||||
|
||||
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(plan.frame.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(plan.frame.height, 408)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 20)
|
||||
}
|
||||
|
||||
func testReflowPlanTracksSideDockVisibleWidthWithoutBottomInsetInflation() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957)
|
||||
|
||||
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(plan.frame.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(plan.frame.height, 408)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 18)
|
||||
}
|
||||
|
||||
func testContentBottomInsetReservesBottomDockSpace() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
|
||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(inset, 20)
|
||||
}
|
||||
|
||||
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||
|
||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(inset, 18)
|
||||
}
|
||||
|
||||
func testCommandNumberShortcutsMapToCollections() {
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command), .mostRecent)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: .command), .mostUsed)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: .command), .text)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: .command), .links)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: .command), .images)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: .command), .files)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: .command), .pinned)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: .command), .audio)
|
||||
}
|
||||
|
||||
func testCollectionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
|
||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .shift]))
|
||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
|
||||
}
|
||||
}
|
||||
551
tests/clipboredtests/ClipboardPanelViewModelTests.swift
Normal file
551
tests/clipboredtests/ClipboardPanelViewModelTests.swift
Normal file
@@ -0,0 +1,551 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
623
tests/clipboredtests/ClipboardPanelViewTests.swift
Normal file
623
tests/clipboredtests/ClipboardPanelViewTests.swift
Normal file
@@ -0,0 +1,623 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardPanelViewTests: XCTestCase {
|
||||
private var tempURLs: [URL] = []
|
||||
|
||||
private struct PanelFixture {
|
||||
let window: NSWindow
|
||||
let view: ClipboardPanelView
|
||||
let viewModel: ClipboardPanelViewModel
|
||||
let settings: SettingsModel
|
||||
let store: ClipboardStore
|
||||
let cacheService: ClipboardCacheService
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||
tempURLs.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSearchFieldEditingWhenSearchFieldIsFirstResponder() {
|
||||
let (window, view) = makePanelWithPanelView()
|
||||
|
||||
window.makeFirstResponder(view)
|
||||
XCTAssertFalse(view.isSearchFieldEditing)
|
||||
|
||||
view.focusSearchField()
|
||||
XCTAssertTrue(view.isSearchFieldEditing)
|
||||
}
|
||||
|
||||
func testSearchFieldEditingWhenFieldEditorIsFirstResponder() {
|
||||
let (window, view) = makePanelWithPanelView()
|
||||
|
||||
view.focusSearchField()
|
||||
guard let editor = window.fieldEditor(false, for: nil) else {
|
||||
return XCTFail("Expected a search field editor")
|
||||
}
|
||||
window.makeFirstResponder(editor)
|
||||
|
||||
XCTAssertTrue(view.isSearchFieldEditing)
|
||||
}
|
||||
|
||||
func testCapturedTextItemCreatesVisibleCardDocument() {
|
||||
let fixture = makePanelFixture()
|
||||
let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id])
|
||||
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
|
||||
XCTAssertEqual(fixture.view.debugResultCountText, "1 clip")
|
||||
XCTAssertTrue(fixture.view.debugDocumentViewIsCardStack)
|
||||
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292)
|
||||
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 244)
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"])
|
||||
}
|
||||
|
||||
func testFooterShowsCaptureStatusInsteadOfShortcutInstructions() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store))
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugStatusText, "Capture running")
|
||||
XCTAssertEqual(fixture.view.debugStatusTone, "ready")
|
||||
XCTAssertFalse(fixture.view.debugStatusText.contains("Enter paste"))
|
||||
}
|
||||
|
||||
func testSkippedCaptureStatusUsesWarningTone() {
|
||||
let fixture = makePanelFixture()
|
||||
|
||||
fixture.settings.setCaptureStatus(message: "Skipped: Audio items are ignored in capture settings.")
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugStatusText, "Skipped: Audio items are ignored in capture settings.")
|
||||
XCTAssertEqual(fixture.view.debugStatusTone, "warning")
|
||||
}
|
||||
|
||||
func testPanelShellRendersAsSquareDockedSurface() {
|
||||
let (_, view) = makePanelWithPanelView()
|
||||
drainMainQueue()
|
||||
view.layoutSubtreeIfNeeded()
|
||||
view.displayIfNeeded()
|
||||
|
||||
let rep = try! XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
|
||||
rep.size = view.bounds.size
|
||||
view.cacheDisplay(in: view.bounds, to: rep)
|
||||
let width = rep.pixelsWide
|
||||
let height = rep.pixelsHigh
|
||||
func alphaAt(_ x: Int, _ y: Int) -> CGFloat {
|
||||
rep.colorAt(x: x, y: y)?.alphaComponent ?? 0
|
||||
}
|
||||
|
||||
XCTAssertEqual(view.debugPanelCornerRadius, 0)
|
||||
XCTAssertGreaterThan(alphaAt(8, 8), 0.9)
|
||||
XCTAssertGreaterThan(alphaAt(width - 9, 8), 0.9)
|
||||
XCTAssertGreaterThan(alphaAt(8, height - 9), 0.9)
|
||||
XCTAssertGreaterThan(alphaAt(width - 9, height - 9), 0.9)
|
||||
}
|
||||
|
||||
func testOpeningTransitionDefersCardRailReloadUntilAnimationCompletes() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeTextItem("Existing clip", store: fixture.store))
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
|
||||
|
||||
fixture.view.beginOpeningTransition()
|
||||
XCTAssertTrue(fixture.view.debugIsDeferringVisualReloads)
|
||||
fixture.store.upsert(makeTextItem("New clip during open", store: fixture.store))
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
|
||||
XCTAssertEqual(fixture.view.debugResultCountText, "2 clips")
|
||||
|
||||
fixture.view.finishOpeningTransition()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertFalse(fixture.view.debugIsDeferringVisualReloads)
|
||||
XCTAssertEqual(fixture.view.debugVisibleCardCount, 2)
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels.first, "Text: New clip during open")
|
||||
}
|
||||
|
||||
func testCollectionRailUsesPasteStyleLabelsAndTracksSelection() {
|
||||
let fixture = makePanelFixture()
|
||||
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugCollectionTitles,
|
||||
["Clipboard", "Frequent", "Text", "Links", "Images", "Audio", "Files", "Pinned"]
|
||||
)
|
||||
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard")
|
||||
|
||||
fixture.viewModel.sortMode = .links
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
|
||||
}
|
||||
|
||||
func testSelectedCardActionsRespectSelectedKind() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Delete"])
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 126)
|
||||
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
|
||||
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
|
||||
|
||||
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))
|
||||
drainMainQueue()
|
||||
|
||||
fixture.viewModel.selectFirstItem()
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Open", "Reveal", "Delete"])
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182)
|
||||
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
|
||||
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
|
||||
}
|
||||
|
||||
func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardHeaderBadgeSymbols, ["link"])
|
||||
}
|
||||
|
||||
func testCollectionRailShowsLiveCounts() {
|
||||
let fixture = makePanelFixture()
|
||||
var pinned = makeTextItem("Pinned note", store: fixture.store)
|
||||
pinned.isPinned = true
|
||||
let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store)
|
||||
let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store)
|
||||
let image = makeItem(kind: .image, text: "image payload", store: fixture.store)
|
||||
let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store)
|
||||
let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store)
|
||||
|
||||
[pinned, rich, link, image, audio, file].forEach {
|
||||
fixture.store.upsert($0)
|
||||
drainMainQueue()
|
||||
}
|
||||
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.count, 6)
|
||||
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [6, 6, 2, 1, 1, 1, 1, 1])
|
||||
XCTAssertEqual(fixture.view.debugCollectionCounts, [6, 6, 2, 1, 1, 1, 1, 1])
|
||||
}
|
||||
|
||||
func testCollectionRailShowsAssignedCollections() {
|
||||
let fixture = makePanelFixture()
|
||||
var link = makeItem(kind: .url, text: "https://example.com/read", store: fixture.store)
|
||||
link.collectionName = "Useful Links"
|
||||
var note = makeTextItem("Meeting note", store: fixture.store)
|
||||
note.collectionName = "Important Notes"
|
||||
var file = makeItem(kind: .file, text: "/tmp/client-brief.pdf", store: fixture.store)
|
||||
file.collectionName = "Client Work"
|
||||
|
||||
fixture.store.upsert(link)
|
||||
fixture.store.upsert(note)
|
||||
fixture.store.upsert(file)
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Useful Links", "Important Notes", "Client Work"])
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [1, 1, 1])
|
||||
|
||||
fixture.viewModel.selectCollection(named: "Useful Links")
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"])
|
||||
}
|
||||
|
||||
func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
|
||||
let fixture = makePanelFixture()
|
||||
let names = [
|
||||
"Client Work",
|
||||
"Research Archive",
|
||||
"Launch Planning",
|
||||
"Design QA",
|
||||
"Product References",
|
||||
"Reading Stack",
|
||||
"Invoices",
|
||||
"Hiring Pipeline"
|
||||
]
|
||||
|
||||
for name in names {
|
||||
var item = makeTextItem("Collection item \(name)", store: fixture.store)
|
||||
item.collectionName = name
|
||||
fixture.store.upsert(item)
|
||||
}
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleWidth, 0)
|
||||
XCTAssertGreaterThan(
|
||||
fixture.view.debugCollectionRailDocumentWidth,
|
||||
fixture.view.debugCollectionRailVisibleWidth + 1
|
||||
)
|
||||
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work"))
|
||||
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References"))
|
||||
}
|
||||
|
||||
func testFilteredEmptyStateNamesCurrentCollection() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))
|
||||
drainMainQueue()
|
||||
|
||||
fixture.viewModel.sortMode = .images
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No images yet")
|
||||
XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Image clips are saved when the clipboard contains image data.")
|
||||
}
|
||||
|
||||
func testCardsExposeContextMenuActions() {
|
||||
let fixture = makePanelFixture()
|
||||
fixture.store.upsert(makeTextItem("Context menu text", store: fixture.store))
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugFirstCardMenuTitles,
|
||||
["Paste", "Copy", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugFirstCardCollectionMenuTitles,
|
||||
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."]
|
||||
)
|
||||
}
|
||||
|
||||
func testCollectionMenuOffersExistingCustomCollections() {
|
||||
let fixture = makePanelFixture()
|
||||
var existing = makeTextItem("Existing client note", store: fixture.store)
|
||||
existing.collectionName = "Client Work"
|
||||
fixture.store.upsert(existing)
|
||||
fixture.store.upsert(makeTextItem("Unsorted card", store: fixture.store))
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugFirstCardCollectionMenuTitles,
|
||||
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."]
|
||||
)
|
||||
}
|
||||
|
||||
func testBottomSafeInsetIsAppliedToPanelContent() {
|
||||
let fixture = makePanelFixture()
|
||||
|
||||
fixture.view.setBottomSafeInset(108)
|
||||
|
||||
XCTAssertEqual(fixture.view.debugContentInsets.bottom, 108)
|
||||
}
|
||||
|
||||
func testInternalLookingTextDoesNotBecomePrimaryCardTitle() {
|
||||
let fixture = makePanelFixture()
|
||||
let item = makeTextItem("clipbored-flow-test-\(UUID().uuidString)", store: fixture.store)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Text: Copied text"])
|
||||
}
|
||||
|
||||
func testLinkCardsUseReadableTitleAndAddressPreview() {
|
||||
let fixture = makePanelFixture()
|
||||
let item = makeItem(
|
||||
kind: .url,
|
||||
displayText: "Release notes",
|
||||
payload: "https://www.example.com/releases/v1?utm_source=copy",
|
||||
store: fixture.store
|
||||
)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Release notes"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Release notes|example.com/releases/v1|example.com"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-preview"])
|
||||
}
|
||||
|
||||
func testLinkCardsUseMediaPreviewWhenThumbnailExists() throws {
|
||||
let fixture = makePanelFixture()
|
||||
let id = UUID()
|
||||
let paths = try XCTUnwrap(fixture.cacheService.cacheImage(sampleImage(), id: id))
|
||||
let item = ClipboardItem(
|
||||
id: id,
|
||||
kind: .url,
|
||||
displayText: "Lookbook",
|
||||
payload: "https://example.com/lookbook",
|
||||
payloadHash: fixture.store.hashString("https://example.com/lookbook"),
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: "Safari",
|
||||
imagePath: paths.full,
|
||||
thumbnailPath: paths.thumb
|
||||
)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
fixture.viewModel.sortMode = .links
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Lookbook"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Lookbook|example.com/lookbook|example.com"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-media-preview"])
|
||||
}
|
||||
|
||||
func testRichTextCardsUseDisplayTextInsteadOfManagedPayloadPath() {
|
||||
let fixture = makePanelFixture()
|
||||
let item = makeItem(
|
||||
kind: .richText,
|
||||
displayText: "Styled note",
|
||||
payload: "/tmp/clipbored-managed-rich-text.rtf",
|
||||
store: fixture.store
|
||||
)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Rich Text: Styled note"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Styled note|Styled note|11 characters"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["rich-text-preview"])
|
||||
}
|
||||
|
||||
func testFileCardsUseFilenameLocationAndType() {
|
||||
let fixture = makePanelFixture()
|
||||
let fileURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Documents")
|
||||
.appendingPathComponent("Project Plan.pdf")
|
||||
let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Project Plan.pdf"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Project Plan.pdf|~/Documents|PDF"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
|
||||
}
|
||||
|
||||
func testMultipleFileCardsUseCountAndSharedLocation() throws {
|
||||
let fixture = makePanelFixture()
|
||||
let directory = makeTempDirectory()
|
||||
let firstURL = directory.appendingPathComponent("Brief.pdf")
|
||||
let secondURL = directory.appendingPathComponent("Invoice.csv")
|
||||
try Data("brief".utf8).write(to: firstURL)
|
||||
try Data("invoice".utf8).write(to: secondURL)
|
||||
let item = makeItem(
|
||||
kind: .file,
|
||||
displayText: "2 files",
|
||||
payload: FilePayload.payload(from: [firstURL, secondURL]),
|
||||
store: fixture.store
|
||||
)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
|
||||
}
|
||||
|
||||
func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws {
|
||||
let fixture = makePanelFixture()
|
||||
let imageURL = makeTempDirectory().appendingPathComponent("Campaign Reference.png")
|
||||
let imageData = try XCTUnwrap(sampleImage().pngData())
|
||||
try imageData.write(to: imageURL)
|
||||
let item = makeItem(kind: .file, displayText: "File", payload: imageURL.path, store: fixture.store)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
fixture.viewModel.sortMode = .files
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Campaign Reference.png"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-media-preview"])
|
||||
}
|
||||
|
||||
func testPdfAndImageCardsUseSpecificPreviewText() {
|
||||
let fixture = makePanelFixture()
|
||||
let pdfURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Downloads")
|
||||
.appendingPathComponent("Reference Guide.pdf")
|
||||
let pdf = makeItem(
|
||||
kind: .pdf,
|
||||
displayText: "PDF",
|
||||
payload: pdfURL.path,
|
||||
store: fixture.store,
|
||||
ocrText: "Quarterly metrics\nSecond page"
|
||||
)
|
||||
|
||||
fixture.store.upsert(pdf)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["PDF: Reference Guide.pdf"])
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugCardPreviewSummaries,
|
||||
["Reference Guide.pdf|Quarterly metrics Second page|PDF"]
|
||||
)
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
|
||||
|
||||
let image = makeItem(
|
||||
kind: .image,
|
||||
displayText: "Image",
|
||||
payload: "",
|
||||
store: fixture.store,
|
||||
ocrText: "Receipt total $42"
|
||||
)
|
||||
fixture.store.upsert(image)
|
||||
fixture.viewModel.sortMode = .images
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Receipt total $42"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Receipt total $42|Receipt total $42|OCR text"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-fallback-preview"])
|
||||
}
|
||||
|
||||
func testImageCardsUseMediaPreviewWhenThumbnailExists() {
|
||||
let fixture = makePanelFixture()
|
||||
let item = makeCachedImageItem(store: fixture.store, cacheService: fixture.cacheService)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
fixture.viewModel.sortMode = .images
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Campaign portrait"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["media-preview"])
|
||||
}
|
||||
|
||||
func testAudioCardsUseSpecificPreviewText() {
|
||||
let fixture = makePanelFixture()
|
||||
let item = makeItem(
|
||||
kind: .audio,
|
||||
displayText: "Audio (14 KB)",
|
||||
payload: "/tmp/clipbored-audio.sound",
|
||||
store: fixture.store
|
||||
)
|
||||
|
||||
fixture.store.upsert(item)
|
||||
fixture.viewModel.sortMode = .audio
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Audio: Audio (14 KB)"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Audio (14 KB)|Sound clip|Audio"])
|
||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"])
|
||||
}
|
||||
|
||||
private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) {
|
||||
let fixture = makePanelFixture()
|
||||
return (fixture.window, fixture.view)
|
||||
}
|
||||
|
||||
private func makePanelFixture() -> PanelFixture {
|
||||
let settings = makeSettings()
|
||||
let cacheService = ClipboardCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
|
||||
let view = ClipboardPanelView(
|
||||
viewModel: viewModel,
|
||||
onClose: {},
|
||||
onSettings: {}
|
||||
)
|
||||
|
||||
let window = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 520),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.contentView = view
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return PanelFixture(
|
||||
window: window,
|
||||
view: view,
|
||||
viewModel: viewModel,
|
||||
settings: settings,
|
||||
store: store,
|
||||
cacheService: cacheService
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSettings() -> SettingsModel {
|
||||
let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.viewtest.\(UUID().uuidString)")!)
|
||||
settings.maxHistoryItems = 10
|
||||
settings.pruneDuplicates = false
|
||||
return settings
|
||||
}
|
||||
|
||||
private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore {
|
||||
ClipboardStore(settings: settings, cacheService: cacheService, baseURL: makeTempDirectory())
|
||||
}
|
||||
|
||||
private func makeTempDirectory() -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipbored-viewtest")
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
tempURLs.append(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func makeTextItem(_ text: String, store: ClipboardStore) -> ClipboardItem {
|
||||
makeItem(kind: .text, text: text, store: store)
|
||||
}
|
||||
|
||||
private func makeItem(kind: ClipboardItemKind, text: String, store: ClipboardStore) -> ClipboardItem {
|
||||
makeItem(kind: kind, displayText: text, payload: text, store: store)
|
||||
}
|
||||
|
||||
private func makeItem(
|
||||
kind: ClipboardItemKind,
|
||||
displayText: String,
|
||||
payload: String,
|
||||
store: ClipboardStore,
|
||||
ocrText: String? = nil
|
||||
) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: kind,
|
||||
displayText: displayText,
|
||||
payload: payload,
|
||||
payloadHash: store.hashString(payload),
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: "Ghostty",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
ocrText: ocrText
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCachedImageItem(store: ClipboardStore, cacheService: ClipboardCacheService) -> ClipboardItem {
|
||||
let id = UUID()
|
||||
let paths = cacheService.cacheImage(sampleImage(), id: id)
|
||||
return ClipboardItem(
|
||||
id: id,
|
||||
kind: .image,
|
||||
displayText: "Campaign portrait",
|
||||
payload: paths?.full ?? "",
|
||||
payloadHash: store.hashString("campaign-portrait"),
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: "Photos",
|
||||
imagePath: paths?.full,
|
||||
thumbnailPath: paths?.thumb,
|
||||
ocrText: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func sampleImage() -> NSImage {
|
||||
let image = NSImage(size: NSSize(width: 180, height: 140))
|
||||
image.lockFocus()
|
||||
NSColor(calibratedRed: 1.0, green: 0.76, blue: 0.20, alpha: 1).setFill()
|
||||
NSRect(x: 0, y: 0, width: 180, height: 140).fill()
|
||||
NSColor(calibratedRed: 0.92, green: 0.20, blue: 0.26, alpha: 1).setFill()
|
||||
NSBezierPath(ovalIn: NSRect(x: 34, y: 24, width: 82, height: 82)).fill()
|
||||
NSColor(calibratedRed: 0.05, green: 0.42, blue: 0.86, alpha: 1).setFill()
|
||||
NSBezierPath(roundedRect: NSRect(x: 92, y: 45, width: 58, height: 48), xRadius: 12, yRadius: 12).fill()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
for _ in 0..<20 {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
}
|
||||
}
|
||||
547
tests/clipboredtests/ClipboardStoreTests.swift
Normal file
547
tests/clipboredtests/ClipboardStoreTests.swift
Normal file
@@ -0,0 +1,547 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
import Foundation
|
||||
import AppKit
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardStoreTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var defaultsSuiteName: String!
|
||||
private var cacheService: ClipboardCacheService!
|
||||
private var baseURL: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
defaultsSuiteName = "com.clipbored.teststore.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: defaultsSuiteName)
|
||||
defaults.removePersistentDomain(forName: defaultsSuiteName)
|
||||
baseURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
defaults.removePersistentDomain(forName: defaultsSuiteName)
|
||||
if let baseURL {
|
||||
try? FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
cacheService = nil
|
||||
defaults = nil
|
||||
baseURL = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
func testUpsertMovesDuplicateToFrontAndPersists() throws {
|
||||
let settings = makeSettings(maxHistory: 4)
|
||||
let store = makeStore(settings: settings)
|
||||
let start = Date()
|
||||
|
||||
store.upsert(makeItem("one", displayText: "One", created: start))
|
||||
store.upsert(makeItem("two", displayText: "Two", created: start.addingTimeInterval(-10)))
|
||||
store.upsert(makeItem("three", displayText: "Three", created: start.addingTimeInterval(-20)))
|
||||
|
||||
store.upsert(makeItem("one", displayText: "One (updated)", created: start.addingTimeInterval(-30)))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(store.items.count, 3)
|
||||
XCTAssertEqual(store.items.map(\.payload), ["one", "three", "two"])
|
||||
XCTAssertEqual(store.items.first?.useCount, 2)
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.count, 3)
|
||||
XCTAssertEqual(restored.items.first?.payload, "one")
|
||||
XCTAssertEqual(restored.items.first?.useCount, 2)
|
||||
}
|
||||
|
||||
func testHistoryLimitIsEnforcedByOverflowPurge() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let start = Date()
|
||||
|
||||
(0...50).forEach { i in
|
||||
store.upsert(makeItem("item-\(i)", displayText: "\(i)", created: start.addingTimeInterval(-Double(i))))
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(store.items.count, 50)
|
||||
XCTAssertEqual(store.items.first?.payload, "item-50")
|
||||
XCTAssertEqual(store.items.last?.payload, "item-1")
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.count, 50)
|
||||
XCTAssertFalse(restored.items.contains(where: { $0.payload == "item-0" }))
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-50" }))
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-1" }))
|
||||
}
|
||||
|
||||
func testMarkUsedUpdatesStateAndWritesMutationOnlyOnce() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
|
||||
store.upsert(makeItem("same", displayText: "First", created: Date()))
|
||||
store.upsert(makeItem("same", displayText: "First duplicate", created: Date().addingTimeInterval(1)))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let first = try! XCTUnwrap(store.items.first)
|
||||
let firstID = first.id
|
||||
store.markUsed(firstID)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.count, 1)
|
||||
XCTAssertEqual(restored.items.first?.useCount, 3)
|
||||
XCTAssertEqual(restored.items.first?.displayText, "First duplicate")
|
||||
}
|
||||
|
||||
func testTogglePinPersistsAcrossReload() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
|
||||
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let itemID = try! XCTUnwrap(store.items.first?.id)
|
||||
store.togglePin(itemID)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.first?.isPinned, true)
|
||||
}
|
||||
|
||||
func testSetCollectionPersistsAcrossReload() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
|
||||
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let itemID = try! XCTUnwrap(store.items.first?.id)
|
||||
store.setCollection(itemID, name: " Client Work ")
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.first?.collectionName, "Client Work")
|
||||
|
||||
let restoredID = try! XCTUnwrap(restored.items.first?.id)
|
||||
restored.setCollection(restoredID, name: nil)
|
||||
restored.flushPersistenceForTesting()
|
||||
|
||||
let cleared = makeStore(settings: settings)
|
||||
cleared.flushPersistenceForTesting()
|
||||
XCTAssertNil(cleared.items.first?.collectionName)
|
||||
}
|
||||
|
||||
func testLegacyJSONHistoryMigratesToSQLite() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let itemID = UUID()
|
||||
let legacyJSON = """
|
||||
[
|
||||
{
|
||||
"id": "\(itemID.uuidString)",
|
||||
"kind": 0,
|
||||
"displayText": "Legacy Note",
|
||||
"payload": "legacy payload",
|
||||
"payloadHash": "legacy-hash",
|
||||
"createdAt": "2026-06-27T12:00:00Z",
|
||||
"lastUsedAt": "2026-06-27T12:01:00Z",
|
||||
"useCount": 3,
|
||||
"sourceApp": "Notes",
|
||||
"imagePath": null,
|
||||
"thumbnailPath": null
|
||||
}
|
||||
]
|
||||
"""
|
||||
try legacyJSON.data(using: .utf8)!.write(to: baseURL.appendingPathComponent("history.json"))
|
||||
|
||||
let store = makeStore(settings: settings)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(store.items.count, 1)
|
||||
XCTAssertEqual(store.items.first?.id, itemID)
|
||||
XCTAssertEqual(store.items.first?.payload, "legacy payload")
|
||||
XCTAssertEqual(store.items.first?.useCount, 3)
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
|
||||
}
|
||||
|
||||
func testPinnedItemsSurviveNormalHistoryPrune() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let start = Date()
|
||||
|
||||
store.upsert(makeItem("pinned-old", displayText: "Pinned", created: start.addingTimeInterval(-500)))
|
||||
let pinnedID = try! XCTUnwrap(store.items.first?.id)
|
||||
store.togglePin(pinnedID)
|
||||
|
||||
(0..<60).forEach { index in
|
||||
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
|
||||
XCTAssertEqual(store.items.filter { !$0.isPinned }.count, 50)
|
||||
XCTAssertEqual(store.items.filter(\.isPinned).count, 1)
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
|
||||
}
|
||||
|
||||
func testStorageFilesUsePrivatePermissions() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
|
||||
store.upsert(makeItem("private", displayText: "Private", created: Date()))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(try posixPermissions(baseURL), 0o700)
|
||||
XCTAssertEqual(try posixPermissions(baseURL.appendingPathComponent("history.sqlite")), 0o600)
|
||||
}
|
||||
|
||||
func testStorageDirectoryHonorsEnvironmentOverride() throws {
|
||||
let overrideURL = baseURL.appendingPathComponent("OverrideStorage", isDirectory: true)
|
||||
setenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey, overrideURL.path, 1)
|
||||
defer { unsetenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey) }
|
||||
|
||||
let resolved = ClipboardStore.storageDirectory()
|
||||
|
||||
XCTAssertEqual(resolved.path, overrideURL.standardizedFileURL.path)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: overrideURL.path))
|
||||
XCTAssertEqual(try posixPermissions(overrideURL), 0o700)
|
||||
}
|
||||
|
||||
func testRemoveAllResetsEncryptionKeyAfterClearingDatabase() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
var resetCount = 0
|
||||
let keyData = Data(repeating: 7, count: 32)
|
||||
let encryptionService = ClipboardEncryptionService(
|
||||
keyProvider: { SymmetricKey(data: keyData) },
|
||||
resetProvider: { resetCount += 1 }
|
||||
)
|
||||
let store = makeStore(settings: settings, encryptionService: encryptionService)
|
||||
|
||||
store.upsert(makeItem("first", displayText: "First", created: Date()))
|
||||
store.upsert(makeItem("second", displayText: "Second", created: Date()))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let firstID = try XCTUnwrap(store.items.first?.id)
|
||||
store.remove(firstID)
|
||||
store.flushPersistenceForTesting()
|
||||
XCTAssertEqual(resetCount, 0)
|
||||
|
||||
store.removeAll()
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(resetCount, 1)
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
let restored = makeStore(settings: settings, encryptionService: encryptionService)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertTrue(restored.items.isEmpty)
|
||||
}
|
||||
|
||||
func testRemoveAllCompactsDatabaseFile() throws {
|
||||
let settings = makeSettings(maxHistory: 2000)
|
||||
settings.pruneDuplicates = false
|
||||
let store = makeStore(settings: settings)
|
||||
let payload = String(repeating: "clipbored database compaction payload ", count: 180)
|
||||
|
||||
for index in 0..<60 {
|
||||
store.upsert(makeItem("\(payload)\(index)", displayText: "Large \(index)", created: Date(timeIntervalSince1970: Double(index))))
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let dbURL = baseURL.appendingPathComponent("history.sqlite")
|
||||
let sizeBeforeClear = try fileSize(dbURL)
|
||||
XCTAssertGreaterThan(sizeBeforeClear, 200_000)
|
||||
|
||||
store.removeAll()
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let sizeAfterClear = try fileSize(dbURL)
|
||||
XCTAssertLessThan(sizeAfterClear, sizeBeforeClear / 2)
|
||||
XCTAssertEqual(try posixPermissions(dbURL), 0o600)
|
||||
}
|
||||
|
||||
func testPersistedTextFieldsAreEncryptedAndReload() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let encryptionService = fixedEncryptionService()
|
||||
let store = makeStore(settings: settings, encryptionService: encryptionService)
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Displayed secret \(UUID().uuidString)",
|
||||
payload: "Payload secret \(UUID().uuidString)",
|
||||
payloadHash: "Hash secret \(UUID().uuidString)",
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
||||
useCount: 1,
|
||||
sourceApp: "Secret app \(UUID().uuidString)",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false,
|
||||
sourceAppBundleId: "com.example.secret.\(UUID().uuidString)",
|
||||
ocrText: "OCR secret \(UUID().uuidString)",
|
||||
collectionName: "Collection secret \(UUID().uuidString)"
|
||||
)
|
||||
|
||||
store.upsert(item)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let rawDatabaseText = try databaseText()
|
||||
XCTAssertTrue(rawDatabaseText.contains(ClipboardEncryptionService.marker))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.displayText))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.payload))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.payloadHash))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.sourceApp!))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.ocrText!))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.collectionName!))
|
||||
|
||||
let restored = makeStore(settings: settings, encryptionService: encryptionService)
|
||||
restored.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(restored.items.first?.displayText, item.displayText)
|
||||
XCTAssertEqual(restored.items.first?.payload, item.payload)
|
||||
XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash)
|
||||
XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp)
|
||||
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
||||
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
||||
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
||||
}
|
||||
|
||||
func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let plaintextStore = makeStore(settings: settings, encryptionService: noOpEncryptionService())
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Legacy display \(UUID().uuidString)",
|
||||
payload: "Legacy payload \(UUID().uuidString)",
|
||||
payloadHash: "Legacy hash \(UUID().uuidString)",
|
||||
createdAt: Date(timeIntervalSince1970: 200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
||||
useCount: 1,
|
||||
sourceApp: "Legacy source \(UUID().uuidString)",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false,
|
||||
sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)",
|
||||
ocrText: "Legacy OCR \(UUID().uuidString)",
|
||||
collectionName: "Legacy collection \(UUID().uuidString)"
|
||||
)
|
||||
|
||||
plaintextStore.upsert(item)
|
||||
plaintextStore.flushPersistenceForTesting()
|
||||
XCTAssertTrue(try databaseText().contains(item.payload))
|
||||
|
||||
let encryptionService = fixedEncryptionService()
|
||||
let restored = makeStore(settings: settings, encryptionService: encryptionService)
|
||||
restored.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(restored.items.first?.displayText, item.displayText)
|
||||
XCTAssertEqual(restored.items.first?.payload, item.payload)
|
||||
XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash)
|
||||
XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp)
|
||||
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
||||
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
||||
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
||||
|
||||
let migratedDatabaseText = try databaseText()
|
||||
XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.displayText))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.payload))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.payloadHash))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.sourceApp!))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!))
|
||||
}
|
||||
|
||||
func testDuplicatePDFReplacementRemovesOldAttachment() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let hash = "same-pdf"
|
||||
let oldPath = try XCTUnwrap(cacheService.cachePDF(Data("old".utf8), id: UUID()))
|
||||
let newPath = try XCTUnwrap(cacheService.cachePDF(Data("new".utf8), id: UUID()))
|
||||
|
||||
store.upsert(makePDFItem(path: oldPath, hash: hash, created: Date(timeIntervalSince1970: 10)))
|
||||
store.upsert(makePDFItem(path: newPath, hash: hash, created: Date(timeIntervalSince1970: 20)))
|
||||
store.flushPersistenceForTesting()
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertEqual(store.items.count, 1)
|
||||
XCTAssertEqual(store.items.first?.payload, newPath)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: oldPath))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: newPath))
|
||||
}
|
||||
|
||||
func testDuplicateReplacementClearsStaleImageSearchMetadata() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
settings.keepFirstImage = false
|
||||
let store = makeStore(settings: settings)
|
||||
let staleImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
|
||||
let hash = "same-content"
|
||||
let imageItem = makeImageItem(
|
||||
fullPath: staleImage.full,
|
||||
thumbPath: staleImage.thumb,
|
||||
hash: hash,
|
||||
ocrText: "stale search marker",
|
||||
created: Date(timeIntervalSince1970: 10)
|
||||
)
|
||||
let textItem = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Replacement text",
|
||||
payload: "Replacement text",
|
||||
payloadHash: hash,
|
||||
createdAt: Date(timeIntervalSince1970: 20),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 20),
|
||||
useCount: 1,
|
||||
sourceApp: "Notes",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false,
|
||||
sourceAppBundleId: "com.apple.Notes",
|
||||
ocrText: nil
|
||||
)
|
||||
|
||||
store.upsert(imageItem)
|
||||
store.upsert(textItem)
|
||||
store.flushPersistenceForTesting()
|
||||
cacheService.flushForTesting()
|
||||
|
||||
let item = try XCTUnwrap(store.items.first)
|
||||
XCTAssertEqual(store.items.count, 1)
|
||||
XCTAssertEqual(item.kind, .text)
|
||||
XCTAssertEqual(item.payload, textItem.payload)
|
||||
XCTAssertNil(item.imagePath)
|
||||
XCTAssertNil(item.thumbnailPath)
|
||||
XCTAssertNil(item.ocrText)
|
||||
XCTAssertFalse(item.searchableText.contains("stale search marker"))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.full))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb))
|
||||
}
|
||||
|
||||
private func makeSettings(maxHistory: Int) -> SettingsModel {
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
settings.maxHistoryItems = maxHistory
|
||||
settings.pruneDuplicates = true
|
||||
settings.keepFirstImage = true
|
||||
return settings
|
||||
}
|
||||
|
||||
private func makeStore(settings: SettingsModel) -> ClipboardStore {
|
||||
makeStore(settings: settings, encryptionService: noOpEncryptionService())
|
||||
}
|
||||
|
||||
private func makeStore(settings: SettingsModel, encryptionService: ClipboardEncryptionService) -> ClipboardStore {
|
||||
ClipboardStore(
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
baseURL: baseURL,
|
||||
encryptionService: encryptionService
|
||||
)
|
||||
}
|
||||
|
||||
private func makeItem(_ payload: String, displayText: String, created: Date) -> ClipboardItem {
|
||||
let hash = settingsHash(for: payload)
|
||||
return ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: displayText,
|
||||
payload: payload,
|
||||
payloadHash: hash,
|
||||
createdAt: created,
|
||||
lastUsedAt: created,
|
||||
useCount: 1,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
)
|
||||
}
|
||||
|
||||
private func makePDFItem(path: String, hash: String, created: Date) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .pdf,
|
||||
displayText: "PDF",
|
||||
payload: path,
|
||||
payloadHash: hash,
|
||||
createdAt: created,
|
||||
lastUsedAt: created,
|
||||
useCount: 1,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
)
|
||||
}
|
||||
|
||||
private func makeImageItem(fullPath: String, thumbPath: String, hash: String, ocrText: String?, created: Date) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .image,
|
||||
displayText: "Image",
|
||||
payload: fullPath,
|
||||
payloadHash: hash,
|
||||
createdAt: created,
|
||||
lastUsedAt: created,
|
||||
useCount: 1,
|
||||
sourceApp: "Preview",
|
||||
imagePath: fullPath,
|
||||
thumbnailPath: thumbPath,
|
||||
isPinned: false,
|
||||
sourceAppBundleId: "com.apple.Preview",
|
||||
ocrText: ocrText
|
||||
)
|
||||
}
|
||||
|
||||
private func makeImage(color: NSColor) -> NSImage {
|
||||
let size = NSSize(width: 24, height: 24)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
color.setFill()
|
||||
NSRect(origin: .zero, size: size).fill()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
|
||||
private func settingsHash(for payload: String) -> String {
|
||||
payload
|
||||
}
|
||||
|
||||
private func posixPermissions(_ url: URL) throws -> Int {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777
|
||||
}
|
||||
|
||||
private func fileSize(_ url: URL) throws -> Int64 {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
let size = try XCTUnwrap(attributes[.size] as? NSNumber)
|
||||
return size.int64Value
|
||||
}
|
||||
|
||||
private func databaseText() throws -> String {
|
||||
let data = try Data(contentsOf: baseURL.appendingPathComponent("history.sqlite"))
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
private func noOpEncryptionService() -> ClipboardEncryptionService {
|
||||
ClipboardEncryptionService(keyProvider: { nil })
|
||||
}
|
||||
|
||||
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
|
||||
let keyData = Data(repeating: byte, count: 32)
|
||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||
}
|
||||
}
|
||||
35
tests/clipboredtests/DiagnosticsServiceTests.swift
Normal file
35
tests/clipboredtests/DiagnosticsServiceTests.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class DiagnosticsServiceTests: XCTestCase {
|
||||
func testCountersCanBeIncrementedAndReset() {
|
||||
let diagnostics = DiagnosticsService.shared
|
||||
diagnostics.reset()
|
||||
|
||||
diagnostics.incrementMonitorTick()
|
||||
diagnostics.incrementPasteboardChange()
|
||||
diagnostics.incrementExtractionAttempt()
|
||||
diagnostics.incrementDatabaseMutation()
|
||||
diagnostics.incrementCachePurge()
|
||||
|
||||
// The counters use a serial async queue for low overhead; sync through reset's queue by reading a snapshot.
|
||||
let snapshot = waitForSnapshot { diagnostics.currentSnapshot() }
|
||||
XCTAssertEqual(snapshot.monitorTicks, 1)
|
||||
XCTAssertEqual(snapshot.pasteboardChanges, 1)
|
||||
XCTAssertEqual(snapshot.extractionAttempts, 1)
|
||||
XCTAssertEqual(snapshot.databaseMutations, 1)
|
||||
XCTAssertEqual(snapshot.cachePurges, 1)
|
||||
|
||||
diagnostics.reset()
|
||||
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
|
||||
}
|
||||
|
||||
private func waitForSnapshot(_ snapshot: @escaping () -> DiagnosticsService.Snapshot) -> DiagnosticsService.Snapshot {
|
||||
let expectation = expectation(description: "diagnostics queue")
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
|
||||
expectation.fulfill()
|
||||
}
|
||||
wait(for: [expectation], timeout: 1)
|
||||
return snapshot()
|
||||
}
|
||||
}
|
||||
426
tests/clipboredtests/PasteActionServiceTests.swift
Normal file
426
tests/clipboredtests/PasteActionServiceTests.swift
Normal file
@@ -0,0 +1,426 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class PasteActionServiceTests: XCTestCase {
|
||||
private var tempURLs: [URL] = []
|
||||
|
||||
override func tearDown() {
|
||||
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||
tempURLs.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCopyWritesTextToPasteboard() {
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Hello",
|
||||
payload: "Hello",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
|
||||
}
|
||||
|
||||
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "No target",
|
||||
payload: "No target",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.paste(item, targetApp: nil), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "No target")
|
||||
}
|
||||
|
||||
func testAutomaticPasteActivatesTargetAndSchedulesKeyboardPasteWhenPermissionGranted() throws {
|
||||
var activatedProcessID: pid_t?
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
var didScheduleKeyboardPaste = false
|
||||
let service = PasteActionService(
|
||||
accessibilityPermissionProvider: { true },
|
||||
targetActivator: { app in
|
||||
activatedProcessID = app.processIdentifier
|
||||
return true
|
||||
},
|
||||
keyboardPasteScheduler: { _ in
|
||||
didScheduleKeyboardPaste = true
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.paste(makeTextItem("Paste into target"), targetApp: targetApp), .pasted)
|
||||
XCTAssertEqual(activatedProcessID, targetApp.processIdentifier)
|
||||
XCTAssertTrue(didScheduleKeyboardPaste)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Paste into target")
|
||||
}
|
||||
|
||||
func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws {
|
||||
var didAttemptActivation = false
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
let service = PasteActionService(
|
||||
accessibilityPermissionProvider: { true },
|
||||
targetActivator: { _ in
|
||||
didAttemptActivation = true
|
||||
return false
|
||||
},
|
||||
keyboardPasteScheduler: { _ in
|
||||
XCTFail("Keyboard paste should not be scheduled when target activation fails")
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.paste(makeTextItem("Activation failed"), targetApp: targetApp), .copied)
|
||||
XCTAssertTrue(didAttemptActivation)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Activation failed")
|
||||
}
|
||||
|
||||
func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws {
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
let service = PasteActionService(
|
||||
accessibilityPermissionProvider: { false },
|
||||
targetActivator: { _ in
|
||||
XCTFail("Target should not be activated without Accessibility permission")
|
||||
return true
|
||||
},
|
||||
keyboardPasteScheduler: { _ in
|
||||
XCTFail("Keyboard paste should not be scheduled without Accessibility permission")
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.paste(makeTextItem("Needs permission"), targetApp: targetApp), .copiedNeedsPermission)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Needs permission")
|
||||
}
|
||||
|
||||
func testCopyMissingFileDoesNotClearExistingPasteboard() {
|
||||
let service = PasteActionService()
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
XCTAssertTrue(board.setString("keep me", forType: .string))
|
||||
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
displayText: "Missing file",
|
||||
payload: "/tmp/clipbored-missing-\(UUID().uuidString)",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .failed("Could not write item to clipboard."))
|
||||
XCTAssertEqual(board.string(forType: .string), "keep me")
|
||||
}
|
||||
|
||||
func testCopyEmptyTextDoesNotClearExistingPasteboard() {
|
||||
let service = PasteActionService()
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
XCTAssertTrue(board.setString("keep me", forType: .string))
|
||||
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "",
|
||||
payload: "",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .failed("Could not write item to clipboard."))
|
||||
XCTAssertEqual(board.string(forType: .string), "keep me")
|
||||
}
|
||||
|
||||
func testCopyWritesURLType() {
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
displayText: "Apple",
|
||||
payload: "https://apple.com",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "https://apple.com")
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), "https://apple.com")
|
||||
XCTAssertEqual(
|
||||
NSPasteboard.general.string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")),
|
||||
"Apple"
|
||||
)
|
||||
}
|
||||
|
||||
func testCopyWritesRichTextRTFAndPlainStringFallback() throws {
|
||||
let directory = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
||||
let attributed = NSAttributedString(
|
||||
string: "Styled clipboard text",
|
||||
attributes: [.font: NSFont.boldSystemFont(ofSize: 15)]
|
||||
)
|
||||
let rtfData = try XCTUnwrap(
|
||||
attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:])
|
||||
)
|
||||
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
|
||||
let service = PasteActionService(cacheService: cacheService)
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .richText,
|
||||
displayText: attributed.string,
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .rtf), rtfData)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), attributed.string)
|
||||
}
|
||||
|
||||
func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() {
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .richText,
|
||||
displayText: "Legacy rich text",
|
||||
payload: "Legacy rich text",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Legacy rich text")
|
||||
XCTAssertNil(NSPasteboard.general.data(forType: .rtf))
|
||||
}
|
||||
|
||||
func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws {
|
||||
let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .richText,
|
||||
displayText: "Readable rich text",
|
||||
payload: missingPath,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: missingPath))
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Readable rich text")
|
||||
}
|
||||
|
||||
func testCopyWritesFileReferenceType() throws {
|
||||
let fileURL = try makeTempFile(contents: "file contents")
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
displayText: fileURL.path,
|
||||
payload: fileURL.path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
|
||||
XCTAssertEqual(objects?.first?.standardizedFileURL, fileURL.standardizedFileURL)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), fileURL.path)
|
||||
}
|
||||
|
||||
func testCopyWritesMultipleFileReferences() throws {
|
||||
let firstURL = try makeTempFile(contents: "first file")
|
||||
let secondURL = try makeTempFile(contents: "second file")
|
||||
let payload = FilePayload.payload(from: [firstURL, secondURL])
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
displayText: "2 files",
|
||||
payload: payload,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
|
||||
XCTAssertEqual(objects?.map(\.standardizedFileURL), [firstURL.standardizedFileURL, secondURL.standardizedFileURL])
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), payload)
|
||||
}
|
||||
|
||||
func testCopyWritesPDFData() throws {
|
||||
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
|
||||
let fileURL = try makeTempFile(contents: pdfData)
|
||||
let service = PasteActionService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .pdf,
|
||||
displayText: "PDF",
|
||||
payload: fileURL.path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
|
||||
}
|
||||
|
||||
func testCopyWritesAudioData() throws {
|
||||
let directory = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
||||
let audioData = Data([1, 3, 5, 7, 9])
|
||||
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
|
||||
let service = PasteActionService(cacheService: cacheService)
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .audio,
|
||||
displayText: "Audio",
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
||||
}
|
||||
|
||||
func testCopyWritesEncryptedPDFData() throws {
|
||||
let directory = try makeTempDirectory()
|
||||
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
||||
let pdfData = Data("%PDF-1.4\nencrypted clipbored\n%%EOF".utf8)
|
||||
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
|
||||
let service = PasteActionService(cacheService: cacheService)
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .pdf,
|
||||
displayText: "PDF",
|
||||
payload: path,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(ClipboardEncryptionService.isProtected(try Data(contentsOf: URL(fileURLWithPath: path))))
|
||||
XCTAssertEqual(service.copy(item), .copied)
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
|
||||
}
|
||||
|
||||
private func makeTempFile(contents: String) throws -> URL {
|
||||
try makeTempFile(contents: Data(contents.utf8))
|
||||
}
|
||||
|
||||
private func makeTextItem(_ value: String) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: value,
|
||||
payload: value,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func makeRunningTargetApp() throws -> NSRunningApplication {
|
||||
try XCTUnwrap(
|
||||
NSWorkspace.shared.runningApplications.first {
|
||||
!$0.isTerminated && $0.processIdentifier > 0
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTempFile(contents: Data) throws -> URL {
|
||||
let directory = try makeTempDirectory()
|
||||
let url = directory.appendingPathComponent("payload")
|
||||
try contents.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeTempDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
tempURLs.append(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
|
||||
let keyData = Data(repeating: byte, count: 32)
|
||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||
}
|
||||
}
|
||||
70
tests/clipboredtests/SensitiveContentDetectorTests.swift
Normal file
70
tests/clipboredtests/SensitiveContentDetectorTests.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class SensitiveContentDetectorTests: XCTestCase {
|
||||
func testDetectsKnownSecretFormats() {
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"),
|
||||
.privateKey
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456"),
|
||||
.bearerToken
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("ghp_abcdefghijklmnopqrstuvwxyzABCDE1234567890"),
|
||||
.githubToken
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("AKIA1234567890ABCDEF"),
|
||||
.awsAccessKey
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("xoxb-abcdefghijklmnopqrst"),
|
||||
.slackToken
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("sk_live_abcdefghijklmnop"),
|
||||
.stripeKey
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"),
|
||||
.openAIToken
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("AIzaabcdefghijklmnopqrstuvwxyz123456789"),
|
||||
.googleAPIKey
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature123"),
|
||||
.jsonWebToken
|
||||
)
|
||||
}
|
||||
|
||||
func testDetectsCreditCardWithLuhnCheck() {
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
|
||||
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
|
||||
}
|
||||
|
||||
func testAllowsNormalClipboardText() {
|
||||
XCTAssertNil(SensitiveContentDetector.detect("Project notes for tomorrow"))
|
||||
XCTAssertNil(SensitiveContentDetector.detect("https://www.apple.com/mac/"))
|
||||
XCTAssertNil(SensitiveContentDetector.detect("Remember to request the API key from the platform team"))
|
||||
XCTAssertNil(SensitiveContentDetector.detect("Release token cleanup notes"))
|
||||
}
|
||||
|
||||
func testDetectsOtpOnlyForSensitiveSources() {
|
||||
XCTAssertNil(SensitiveContentDetector.detect("123456"))
|
||||
XCTAssertEqual(
|
||||
SensitiveContentDetector.detect("123456", sourceBundleId: "com.1password.1password", sourceApp: "1Password"),
|
||||
.oneTimeCode
|
||||
)
|
||||
}
|
||||
|
||||
func testDetectsSecretAssignments() {
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz"), .openAIToken)
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("client_secret: supersecretvalue"), .keyword)
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("refresh_token = \"abc1234567890\""), .keyword)
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("passwd='correct-horse-battery'"), .keyword)
|
||||
}
|
||||
}
|
||||
70
tests/clipboredtests/ShortcutManagerTests.swift
Normal file
70
tests/clipboredtests/ShortcutManagerTests.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import AppKit
|
||||
import Carbon
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ShortcutManagerTests: XCTestCase {
|
||||
func testVirtualKeyCodeMappingSupportsDefaults() {
|
||||
XCTAssertEqual(ShortcutManager.virtualKeyCode(for: "v"), UInt16(kVK_ANSI_V))
|
||||
XCTAssertEqual(ShortcutManager.virtualKeyCode(for: ","), UInt16(kVK_ANSI_Comma))
|
||||
}
|
||||
|
||||
func testVirtualKeyCodeRejectsUnsupportedKeys() {
|
||||
XCTAssertNil(ShortcutManager.virtualKeyCode(for: "space"))
|
||||
XCTAssertNil(ShortcutManager.virtualKeyCode(for: ""))
|
||||
}
|
||||
|
||||
func testCarbonModifierMapping() {
|
||||
let flags = NSEvent.ModifierFlags.command.union(.option).rawValue
|
||||
let carbon = ShortcutManager.carbonModifiers(for: flags)
|
||||
XCTAssertTrue(carbon & UInt32(cmdKey) != 0)
|
||||
XCTAssertTrue(carbon & UInt32(optionKey) != 0)
|
||||
XCTAssertFalse(carbon & UInt32(controlKey) != 0)
|
||||
}
|
||||
|
||||
func testRejectsBareGlobalShortcut() {
|
||||
let manager = makeManager(openShortcut: ShortcutBinding(key: "v", modifierFlags: 0))
|
||||
|
||||
XCTAssertEqual(manager.start(), .unsupportedShortcut("V"))
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
func testRejectsShiftOnlyGlobalShortcut() {
|
||||
let manager = makeManager(openShortcut: ShortcutBinding(key: "v", modifierFlags: NSEvent.ModifierFlags.shift.rawValue))
|
||||
|
||||
XCTAssertEqual(manager.start(), .unsupportedShortcut("⇧V"))
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
func testRejectsUnsupportedSettingsShortcutBeforeRegistration() {
|
||||
let manager = makeManager(
|
||||
openShortcut: AppConfiguration.defaultOpenShortcut,
|
||||
settingsShortcut: ShortcutBinding(key: "space", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private func makeManager(
|
||||
openShortcut: ShortcutBinding,
|
||||
settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut
|
||||
) -> ShortcutManager {
|
||||
ShortcutManager(
|
||||
onOpenClipboardPanel: {},
|
||||
onOpenSettings: {},
|
||||
openShortcut: openShortcut,
|
||||
settingsShortcut: settingsShortcut
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user