From 68f65f129f6a5fa5398700c47808ec7f691e641f Mon Sep 17 00:00:00 2001 From: Frank Lyder Bredland Date: Fri, 19 Dec 2025 15:02:12 +0100 Subject: [PATCH] Refactor FuzzyMatch to its own file & add testing Added setup for unit testing using the new [Swift Testing](https://developer.apple.com/xcode/swift-testing/) framwork. Added it to the test pipeline. New `make run` command since `make test` now runs the test suit. Fixes #22 --- .github/workflows/test.yml | 3 ++ Makefile | 17 ++++--- Package.resolved | 24 +++++++++ Package.swift | 29 +++++++++++ README.md | 8 ++- src/FuzzyMatch.swift | 99 +++++++++++++++++++++++++++++++++++++ src/main.swift | 98 ------------------------------------ tests/FuzzyMatchTests.swift | 30 +++++++++++ 8 files changed, 202 insertions(+), 106 deletions(-) create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 src/FuzzyMatch.swift create mode 100644 tests/FuzzyMatchTests.swift diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93b9cd8..54c5350 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,3 +18,6 @@ jobs: - name: Build run: make build + + - name: Test + run: make test diff --git a/Makefile b/Makefile index 260b5ba..34953bd 100644 --- a/Makefile +++ b/Makefile @@ -3,16 +3,18 @@ BINARY_NAME = mac-menu SRC_DIR = src DIST_DIR = .build VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "unknown") +SOURCES = $(SRC_DIR)/FuzzyMatch.swift $(SRC_DIR)/main.swift $(SRC_DIR)/Version.swift -.PHONY: all clean install uninstall test +.PHONY: all clean install uninstall test run +RUN_CMD = $(DIST_DIR)/$(BINARY_NAME) all: $(DIST_DIR)/$(BINARY_NAME) build: all -$(DIST_DIR)/$(BINARY_NAME): $(SRC_DIR)/main.swift $(SRC_DIR)/Version.swift +$(DIST_DIR)/$(BINARY_NAME): $(SOURCES) @mkdir -p $(DIST_DIR) - swiftc -O -o $(DIST_DIR)/$(BINARY_NAME) $(SRC_DIR)/main.swift $(SRC_DIR)/Version.swift -framework Cocoa + swiftc -O -o $(DIST_DIR)/$(BINARY_NAME) $(SOURCES) -framework Cocoa $(SRC_DIR)/Version.swift: @echo 'let appVersion = "$(VERSION)"' > $(SRC_DIR)/Version.swift @@ -31,6 +33,9 @@ clean: @echo "Cleaning build files..." @rm -rf $(DIST_DIR) -test: $(DIST_DIR)/$(BINARY_NAME) - @echo "Testing with Yes/No input..." - @echo "Yes\nNo" | $(DIST_DIR)/$(BINARY_NAME) \ No newline at end of file +test: + @echo "Running Swift Testing suite..." + @swift test --enable-swift-testing + +run: build + @$(RUN_CMD) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..c266223 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "e8982a39da456ab19e3edbb5bd0d90109864ecb64e1e27f3cc7cadda2a65ac3a", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-testing.git", + "state" : { + "revision" : "48a471ab313e858258ab0b9b0bf2cea55a50cefb", + "version" : "6.2.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..ecadc8e --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "mac-menu", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-testing.git", from: "6.2.0") + ], + targets: [ + .target( + name: "FuzzyMatchModule", + path: "src", + exclude: ["main.swift", "Version.swift"], + sources: ["FuzzyMatch.swift"] + ), + .testTarget( + name: "FuzzyMatchTests", + dependencies: [ + "FuzzyMatchModule", + .product(name: "Testing", package: "swift-testing") + ], + path: "tests" + ) + ] +) diff --git a/README.md b/README.md index a8e0b66..31eb687 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,17 @@ make clean ## Testing -A simple test is included to verify the application with basic input. This test will run the app with "Yes" and "No" as options: +Run the Swift Testing suite: ```bash make test ``` -This will build the application (if needed) and launch it with "Yes" and "No" as selectable options. You can interact with the UI as usual (keyboard/mouse), and the selected value will be printed to stdout. +For a quick manual check of the UI, pipe input into the app and launch it via the convenience target: + +```bash +echo -e "Yes\nNo" | make run +``` ## License diff --git a/src/FuzzyMatch.swift b/src/FuzzyMatch.swift new file mode 100644 index 0000000..b499b6d --- /dev/null +++ b/src/FuzzyMatch.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Result of a fuzzy match attempt including match positions for scoring. +struct FuzzyMatchResult { + let string: String + let score: Int + let positions: [Int] +} + +/// Constants used to calculate fuzzy match scores. +private enum ScoreConfig { + static let bonusMatch: Int = 16 + static let bonusBoundary: Int = 16 + static let bonusConsecutive: Int = 16 + static let penaltyGapStart: Int = -3 + static let penaltyGapExtension: Int = -1 + static let penaltyNonContiguous: Int = -5 +} + +/// Fuzzy search function implementing fzf's algorithm. +/// - Parameters: +/// - pattern: The search pattern to match +/// - string: The string to search in +/// - Returns: A tuple containing whether there's a match and the match result +func fuzzyMatch(pattern: String, string: String) -> (Bool, FuzzyMatchResult?) { + let pattern = pattern.lowercased() + let string = string.lowercased() + + // Empty pattern matches everything + if pattern.isEmpty { + return (true, FuzzyMatchResult(string: string, score: 0, positions: [])) + } + + let patternLength = pattern.count + let stringLength = string.count + + // If pattern is longer than string, no match possible + if patternLength > stringLength { + return (false, nil) + } + + // Initialize score matrix + var scores = Array(repeating: Array(repeating: 0, count: stringLength + 1), count: patternLength + 1) + var positions = Array(repeating: Array(repeating: [Int](), count: stringLength + 1), count: patternLength + 1) + + // Fill score matrix + for i in 1...patternLength { + for j in 1...stringLength { + let patternChar = pattern[pattern.index(pattern.startIndex, offsetBy: i - 1)] + let stringChar = string[string.index(string.startIndex, offsetBy: j - 1)] + + if patternChar == stringChar { + var score = ScoreConfig.bonusMatch + + // Bonus for boundary + if j == 1 || string[string.index(string.startIndex, offsetBy: j - 2)] == " " { + score += ScoreConfig.bonusBoundary + } + + // Bonus for consecutive matches + if i > 1 && j > 1 && pattern[pattern.index(pattern.startIndex, offsetBy: i - 2)] == string[string.index(string.startIndex, offsetBy: j - 2)] { + score += ScoreConfig.bonusConsecutive + } + + let prevScore = scores[i - 1][j - 1] + let newScore = prevScore + score + + // Check if we should extend previous match or start new one + if newScore > scores[i - 1][j] + ScoreConfig.penaltyGapStart { + scores[i][j] = newScore + positions[i][j] = positions[i - 1][j - 1] + [j - 1] + } else { + scores[i][j] = scores[i - 1][j] + ScoreConfig.penaltyGapStart + positions[i][j] = positions[i - 1][j] + } + } else { + // Penalty for gaps + let gapScore = max( + scores[i][j - 1] + ScoreConfig.penaltyGapExtension, + scores[i - 1][j] + ScoreConfig.penaltyGapStart + ) + scores[i][j] = gapScore + positions[i][j] = positions[i][j - 1] + } + } + } + + // Check if we found a match + let finalScore = scores[patternLength][stringLength] + if finalScore > 0 { + return (true, FuzzyMatchResult( + string: string, + score: finalScore, + positions: positions[patternLength][stringLength] + )) + } + + return (false, nil) +} diff --git a/src/main.swift b/src/main.swift index 1e3cc80..d70af64 100644 --- a/src/main.swift +++ b/src/main.swift @@ -74,104 +74,6 @@ class MenuApp: NSObject, NSApplicationDelegate, NSTableViewDataSource, NSTableVi /// Items filtered by search query var filteredItems: [String] = [] - /// Fuzzy search result containing the matched string and its score - private struct FuzzyMatchResult { - let string: String - let score: Int - let positions: [Int] - } - - /// Constants for scoring - private struct ScoreConfig { - static let bonusMatch: Int = 16 - static let bonusBoundary: Int = 16 - static let bonusConsecutive: Int = 16 - static let penaltyGapStart: Int = -3 - static let penaltyGapExtension: Int = -1 - static let penaltyNonContiguous: Int = -5 - } - - /// Fuzzy search function implementing fzf's algorithm - /// - Parameters: - /// - pattern: The search pattern to match - /// - string: The string to search in - /// - Returns: A tuple containing whether there's a match and the match result - private func fuzzyMatch(pattern: String, string: String) -> (Bool, FuzzyMatchResult?) { - let pattern = pattern.lowercased() - let string = string.lowercased() - - // Empty pattern matches everything - if pattern.isEmpty { - return (true, FuzzyMatchResult(string: string, score: 0, positions: [])) - } - - let patternLength = pattern.count - let stringLength = string.count - - // If pattern is longer than string, no match possible - if patternLength > stringLength { - return (false, nil) - } - - // Initialize score matrix - var scores = Array(repeating: Array(repeating: 0, count: stringLength + 1), count: patternLength + 1) - var positions = Array(repeating: Array(repeating: [Int](), count: stringLength + 1), count: patternLength + 1) - - // Fill score matrix - for i in 1...patternLength { - for j in 1...stringLength { - let patternChar = pattern[pattern.index(pattern.startIndex, offsetBy: i - 1)] - let stringChar = string[string.index(string.startIndex, offsetBy: j - 1)] - - if patternChar == stringChar { - var score = ScoreConfig.bonusMatch - - // Bonus for boundary - if j == 1 || string[string.index(string.startIndex, offsetBy: j - 2)] == " " { - score += ScoreConfig.bonusBoundary - } - - // Bonus for consecutive matches - if i > 1 && j > 1 && pattern[pattern.index(pattern.startIndex, offsetBy: i - 2)] == string[string.index(string.startIndex, offsetBy: j - 2)] { - score += ScoreConfig.bonusConsecutive - } - - let prevScore = scores[i - 1][j - 1] - let newScore = prevScore + score - - // Check if we should extend previous match or start new one - if newScore > scores[i - 1][j] + ScoreConfig.penaltyGapStart { - scores[i][j] = newScore - positions[i][j] = positions[i - 1][j - 1] + [j - 1] - } else { - scores[i][j] = scores[i - 1][j] + ScoreConfig.penaltyGapStart - positions[i][j] = positions[i - 1][j] - } - } else { - // Penalty for gaps - let gapScore = max( - scores[i][j - 1] + ScoreConfig.penaltyGapExtension, - scores[i - 1][j] + ScoreConfig.penaltyGapStart - ) - scores[i][j] = gapScore - positions[i][j] = positions[i][j - 1] - } - } - } - - // Check if we found a match - let finalScore = scores[patternLength][stringLength] - if finalScore > 0 { - return (true, FuzzyMatchResult( - string: string, - score: finalScore, - positions: positions[patternLength][stringLength] - )) - } - - return (false, nil) - } - // MARK: - Application Lifecycle /// Sets up the application window and UI components diff --git a/tests/FuzzyMatchTests.swift b/tests/FuzzyMatchTests.swift new file mode 100644 index 0000000..5c9d581 --- /dev/null +++ b/tests/FuzzyMatchTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import FuzzyMatchModule + +struct FuzzyMatchTests { + @Test func emptyPatternMatchesEverything() throws { + let (matched, result) = fuzzyMatch(pattern: "", string: "Hello World") + #expect(matched, "Empty pattern should match any string") + #expect(result != nil, "Result should not be nil for empty pattern") + #expect(result?.positions.isEmpty == true, "Empty pattern should have no positions") + } + + @Test func patternLongerThanStringDoesNotMatch() throws { + let (matched, result) = fuzzyMatch(pattern: "toolong", string: "tiny") + #expect(!matched, "Longer pattern should not match shorter string") + #expect(result == nil, "Result should be nil when there is no match") + } + + @Test func exactMatchProducesSequentialPositions() throws { + let (matched, result) = fuzzyMatch(pattern: "abc", string: "abc") + #expect(matched, "Exact match should succeed") + #expect(result?.positions == [0, 1, 2], "Exact match should return sequential positions") + #expect((result?.score ?? 0) > 0, "Exact match should have a positive score") + } + + @Test func caseInsensitiveMatching() throws { + let (matched, result) = fuzzyMatch(pattern: "FZ", string: "fuzzy") + #expect(matched, "Matching should be case-insensitive") + #expect(result?.positions == [0, 3], "Expected positions for 'FZ' in 'fuzzy' to be [0, 3]") + } +}