Files
conjure/Tests/ConjureKitTests/ApplicationsTests.swift
T
2026-10-15 10:13:27 +02:00

177 lines
8.5 KiB
Swift

import ConjureKit
import Foundation
import Testing
@Test func searchRanksExactPrefixWordPrefixInitialsSubstringAndFuzzyMatches() {
let names = ["Logic", "Agc Utility", "Great Console", "Google Chrome", "Tool GC Helper", "GCompiler", "GC", "Unrelated"]
let applications = names.map(application)
#expect(ApplicationSearch.results(for: "gc", in: applications).map(\.name) == [
"GC", "GCompiler", "Tool GC Helper", "Google Chrome", "Great Console", "Agc Utility", "Logic",
])
#expect(ApplicationSearch.results(for: "zq", in: applications).isEmpty)
}
@Test func wordPrefixesIncludePartialPhrasesWithoutPromotingInteriorSubstrings() {
let applications = ["ADisk Utility", "Open Disk Utility", "Disk Utility"].map(application)
#expect(ApplicationSearch.results(for: "disk u", in: applications).map(\.name) == [
"Disk Utility", "Open Disk Utility", "ADisk Utility",
])
}
@Test func wordStartsHandlePunctuationDigitsAndAccentFolding() {
let cases = [
("ss", "System_Settings", "Mississippi"),
("2m", "Audio 2 MIDI", "A2M Tools"),
(" \n ÉN \t ", "Éditeur Numérique", "Aen Utility"),
("sys", "★ System Settings", "Asystem"),
]
for (query, name, interiorMatch) in cases {
let applications = [interiorMatch, name].map(application)
#expect(ApplicationSearch.results(for: query, in: applications).map(\.name) == [
name, interiorMatch,
])
}
}
@Test func wordInitialsMatchInOrderAndCanSkipWords() {
let applications = ["Vocal", "Avoid Chords", "Voice Control", "Visual Studio Code"].map(application)
#expect(ApplicationSearch.results(for: "vc", in: applications).map(\.name) == [
"Visual Studio Code", "Voice Control", "Vocal", "Avoid Chords",
])
#expect(ApplicationSearch.results(for: "vsc", in: applications).map(\.name) == [
"Visual Studio Code",
])
#expect(ApplicationSearch.results(for: "cv", in: applications).isEmpty)
}
@Test func searchFoldsAccentsAndTrimsWhitespace() {
let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application)
#expect(ApplicationSearch.results(for: " \n CAFÉ \t ", in: applications).map(\.name) == [
"Café", "Caféteria", "Decafé",
])
}
@Test func fuzzyMatchesPreferCompactSpansThenEarlierStartsAndAlphabeticalTies() {
let applications = ["AxxxxB", "xAyB", "AzB", "AyB", "B before A"].map(application)
#expect(ApplicationSearch.results(for: "ab", in: applications).map(\.name) == [
"AyB", "AzB", "xAyB", "AxxxxB",
])
}
@Test func emptySearchSortsAlphabeticallyAndBreaksDuplicateNameTiesByURL() {
let first = Application(url: URL(fileURLWithPath: "/First/Tools.app"), name: "Tools")
let second = Application(url: URL(fileURLWithPath: "/Second/Tools.app"), name: "Tools")
let applications = [second, first, application("Alpha")]
for query in ["", " \n\t "] {
#expect(ApplicationSearch.results(for: query, in: applications).map(\.url) == [
applications[2].url, first.url, second.url,
])
}
#expect(ApplicationSearch.results(for: "tools", in: applications).map(\.url) == [first.url, second.url])
}
@Test func discoveryFindsNestedAppsButNotHiddenInvalidOrBundledHelpers() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let nested = root.appendingPathComponent("Utilities", isDirectory: true)
let alpha = try appBundle(in: nested, filename: "Alpha", name: "Alpha")
let parent = try appBundle(in: root, filename: "Parent", name: "Parent")
_ = try appBundle(in: parent.appendingPathComponent("Contents/Helpers"), filename: "Helper", name: "Helper")
_ = try appBundle(in: root, filename: ".Hidden", name: "Hidden")
_ = try appBundle(in: root, filename: "NoExecutable", name: "NoExecutable", executable: false)
_ = try appBundle(in: root, filename: "WrongType", name: "WrongType", packageType: "BNDL")
let fallback = try appBundle(in: root, filename: "Fallback", name: nil)
let display = try appBundle(in: root, filename: "Filename", name: "Internal", displayName: "Unlocalized")
let localization = display.appendingPathComponent("Contents/Resources/en.lproj", isDirectory: true)
try FileManager.default.createDirectory(at: localization, withIntermediateDirectories: true)
let localizedInfo = try PropertyListSerialization.data(
fromPropertyList: ["CFBundleDisplayName": "Display"], format: .xml, options: 0
)
try localizedInfo.write(to: localization.appendingPathComponent("InfoPlist.strings"))
let invalid = root.appendingPathComponent("Invalid.app", isDirectory: true)
_ = try appBundle(in: invalid, filename: "OtherHelper", name: "OtherHelper")
let applications = ApplicationCatalog.discover(in: [root, root.appendingPathComponent("Missing")])
#expect(applications.map(\.name) == ["Alpha", "Display", "Fallback", "Parent"])
#expect(applications.map(\.url) == [alpha, display, fallback, parent].map { $0.resolvingSymlinksInPath() })
}
@Test func discoveryFindsSearchableXPCAppsButNotTheirHelpersOrInvalidBundles() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let passwords = try appBundle(in: root, filename: "Passwords", name: "Passwords", packageType: "XPC!")
_ = try appBundle(in: passwords.appendingPathComponent("Contents/Helpers"),
filename: "Helper", name: "Helper", packageType: "XPC!")
_ = try appBundle(in: root, filename: "NoExecutable", name: "NoExecutable",
executable: false, packageType: "XPC!")
let applications = ApplicationCatalog.discover(in: [root])
#expect(applications.map(\.url) == [passwords.resolvingSymlinksInPath()])
#expect(ApplicationSearch.results(for: "passwords", in: applications).map(\.name) == ["Passwords"])
}
@Test func discoveryCanonicalizesAliasesAndAvoidsDirectorySymlinkCycles() throws {
let root = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let nested = root.appendingPathComponent("Nested", isDirectory: true)
let app = try appBundle(in: nested, filename: "Editor", name: "Editor")
let alias = root.appendingPathComponent("Alias.app")
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app)
try FileManager.default.createSymbolicLink(at: nested.appendingPathComponent("Loop"), withDestinationURL: root)
let linkedDirectory = root.appendingPathComponent("LinkedDirectory")
try FileManager.default.createSymbolicLink(at: linkedDirectory, withDestinationURL: nested)
let applications = ApplicationCatalog.discover(in: [root, linkedDirectory, root])
#expect(applications.count == 1)
#expect(applications.first?.id == app.resolvingSymlinksInPath())
#expect(Application(url: alias, name: "Alias").id == applications.first?.id)
}
private func application(_ name: String) -> Application {
Application(url: URL(fileURLWithPath: "/Applications/\(name).app"), name: name)
}
private func temporaryDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@discardableResult
private func appBundle(
in directory: URL,
filename: String,
name: String?,
displayName: String? = nil,
executable: Bool = true,
packageType: String = "APPL"
) throws -> URL {
let url = directory.appendingPathComponent("\(filename).app", isDirectory: true)
let contents = url.appendingPathComponent("Contents", isDirectory: true)
let macOS = contents.appendingPathComponent("MacOS", isDirectory: true)
try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true)
var info: [String: Any] = [
"CFBundlePackageType": packageType,
"CFBundleExecutable": "Launcher",
"CFBundleDevelopmentRegion": "en",
"CFBundleIdentifier": "test.conjure.\(UUID().uuidString)",
]
info["CFBundleName"] = name
info["CFBundleDisplayName"] = displayName
let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
try data.write(to: contents.appendingPathComponent("Info.plist"))
if executable {
let binary = macOS.appendingPathComponent("Launcher")
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binary)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
}
return url
}