diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..26cc739 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,75 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + merge_group: + +jobs: + test: + runs-on: macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show Xcode version + run: | + xcodebuild -version + swift --version + + - name: Configure project for CI + run: | + cp .env.example .env + { + echo "DEVELOPMENT_TEAM=AAAAAAAAAA" + echo "BUNDLE_IDENTIFIER=com.example.zettel" + echo "CONFIGURATION=Debug" + } >> .env + ./configure.sh + + - name: Run ZettelKit package tests + run: swift test --package-path Packages/ZettelKit + + - name: Run macOS tests + run: xcodebuild test -project Zettel.xcodeproj -scheme ZettelMac -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO + + - name: Resolve iOS simulator destination + id: ios-destination + shell: bash + run: | + set -euo pipefail + destination=$(xcrun simctl list devices available --json | ruby -rjson -e ' + devices = JSON.parse(STDIN.read).fetch("devices") + candidates = devices.flat_map do |runtime, entries| + next [] unless runtime.include?("iOS") + entries.select { |entry| entry["isAvailable"] && entry["name"].include?("iPhone") } + .map { |entry| [runtime, entry["udid"]] } + end + abort("No available iOS simulator found") if candidates.empty? + + best = candidates.max_by do |runtime, _| + version = runtime[/iOS[- ](.+)$/, 1].to_s + version.split(/[-.]/).map(&:to_i) + end + + puts "platform=iOS Simulator,id=#{best[1]}" + ') + echo "value=$destination" >> "$GITHUB_OUTPUT" + + - name: Run iOS tests + run: xcodebuild test -project Zettel.xcodeproj -scheme Zettel -destination '${{ steps.ios-destination.outputs.value }}' CODE_SIGNING_ALLOWED=NO diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06506d8..4be0a1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,8 @@ Thank you for your interest in contributing to Zettel! This guide will help you - `Stores/` - macOS state management - `Extensions/` - macOS-specific extensions - `Packages/ZettelKit/` - Shared Swift package (models, stores, utilities used by both targets) -- `ZettelTests/` - Unit tests +- `ZettelTests/` - iOS logic tests +- `ZettelMacTests/` - macOS logic tests ## Development Workflow @@ -73,6 +74,10 @@ Thank you for your interest in contributing to Zettel! This guide will help you 3. **Before Committing** - Run `./clean.sh` to remove personal configuration - Ensure `.env` is not committed (it's in `.gitignore`) + - Run the logic test suites: + - `swift test --package-path Packages/ZettelKit` + - `xcodebuild test -project Zettel.xcodeproj -scheme ZettelMac -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO` + - `xcodebuild test -project Zettel.xcodeproj -scheme Zettel -destination 'platform=iOS Simulator,name=iPhone 16' CODE_SIGNING_ALLOWED=NO` - Test that the project builds from a clean state 4. **Submitting Changes** diff --git a/Packages/ZettelKit/Package.swift b/Packages/ZettelKit/Package.swift index 8fb411f..f3b4e32 100644 --- a/Packages/ZettelKit/Package.swift +++ b/Packages/ZettelKit/Package.swift @@ -18,5 +18,9 @@ let package = Package( .target( name: "ZettelKit" ), + .testTarget( + name: "ZettelKitTests", + dependencies: ["ZettelKit"] + ), ] ) diff --git a/Packages/ZettelKit/Sources/ZettelKit/DefaultTitleTemplateManager.swift b/Packages/ZettelKit/Sources/ZettelKit/DefaultTitleTemplateManager.swift index 663a1ed..f667cd5 100644 --- a/Packages/ZettelKit/Sources/ZettelKit/DefaultTitleTemplateManager.swift +++ b/Packages/ZettelKit/Sources/ZettelKit/DefaultTitleTemplateManager.swift @@ -8,28 +8,38 @@ import Foundation /// Manages the template used for generating default note titles. -public final class DefaultTitleTemplateManager: Sendable { +public final class DefaultTitleTemplateManager: @unchecked Sendable { public static let shared = DefaultTitleTemplateManager() /// Storage key for persisting the custom template. - private let storageKey = "defaultTitleTemplate" + private let storageKey: String + private let timeZone: TimeZone + private let userDefaults: UserDefaults /// Built-in fallback template used when no custom template is set. public let fallbackTemplate = "{{shortDate}} – {{time}}" - private init() {} + public init( + userDefaults: UserDefaults = .standard, + storageKey: String = "defaultTitleTemplate", + timeZone: TimeZone = .autoupdatingCurrent + ) { + self.userDefaults = userDefaults + self.storageKey = storageKey + self.timeZone = timeZone + } /// Returns the saved custom template if present. public func savedTemplate() -> String? { - UserDefaults.standard.string(forKey: storageKey) + userDefaults.string(forKey: storageKey) } /// Persists a new template. public func saveTemplate(_ template: String) { if template.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - UserDefaults.standard.removeObject(forKey: storageKey) + userDefaults.removeObject(forKey: storageKey) } else { - UserDefaults.standard.set(template, forKey: storageKey) + userDefaults.set(template, forKey: storageKey) } } @@ -53,6 +63,7 @@ public final class DefaultTitleTemplateManager: Sendable { public func fallbackTitle(for date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone formatter.dateFormat = "HH-mm-ss – d MMM yyyy" return formatter.string(from: date) } @@ -81,6 +92,7 @@ public final class DefaultTitleTemplateManager: Sendable { private func makeFormatter(_ format: String) -> DateFormatter { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone formatter.dateFormat = format return formatter } diff --git a/Packages/ZettelKit/Sources/ZettelKit/Note.swift b/Packages/ZettelKit/Sources/ZettelKit/Note.swift index ec0cdd6..57fbb54 100644 --- a/Packages/ZettelKit/Sources/ZettelKit/Note.swift +++ b/Packages/ZettelKit/Sources/ZettelKit/Note.swift @@ -49,11 +49,12 @@ public final class TagCacheManager: @unchecked Sendable { } public func getExtractedTags(for note: Note) -> Set { - let noteId = NSString(string: note.id) + let noteId = note.id let contentHash = (note.title + note.content).hashValue return cacheQueue.sync { - if let cached = cache.object(forKey: noteId), cached.hash == contentHash { + let cacheKey = NSString(string: noteId) + if let cached = cache.object(forKey: cacheKey), cached.hash == contentHash { return cached.tags } @@ -61,7 +62,7 @@ public final class TagCacheManager: @unchecked Sendable { let cachedTags = CachedTags(hash: contentHash, tags: tags) cacheQueue.async(flags: .barrier) { - self.cache.setObject(cachedTags, forKey: noteId) + self.cache.setObject(cachedTags, forKey: NSString(string: noteId)) } return tags diff --git a/Packages/ZettelKit/Sources/ZettelKit/NoteFileRepository.swift b/Packages/ZettelKit/Sources/ZettelKit/NoteFileRepository.swift new file mode 100644 index 0000000..28fc477 --- /dev/null +++ b/Packages/ZettelKit/Sources/ZettelKit/NoteFileRepository.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Foundation-only note file persistence used by app stores and tests. +public struct NoteFileRepository: Sendable { + public struct SaveResult: Equatable, Sendable { + public let filename: String + public let fileURL: URL + + public init(filename: String, fileURL: URL) { + self.filename = filename + self.fileURL = fileURL + } + } + + public init() {} + + public func createDirectoryIfNeeded(at directory: URL) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + public func loadNotes(in directory: URL) throws -> [Note] { + let fileURLs = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + + return try fileURLs + .filter { $0.pathExtension == "md" } + .map(loadRequiredNote(from:)) + .sorted { $0.modifiedAt > $1.modifiedAt } + } + + public func loadNote(from url: URL) -> Note? { + try? loadRequiredNote(from: url) + } + + @discardableResult + public func save(_ note: Note, in directory: URL, originalFilename: String? = nil) throws -> SaveResult { + try createDirectoryIfNeeded(at: directory) + + let targetFilename: String + if let originalFilename, originalFilename == note.filename { + targetFilename = originalFilename + } else { + targetFilename = note.generateUniqueFilename(in: directory) + } + + let targetURL = directory.appendingPathComponent(targetFilename) + try note.serializedContent.write(to: targetURL, atomically: true, encoding: .utf8) + + if let originalFilename, originalFilename != targetFilename { + let originalURL = directory.appendingPathComponent(originalFilename) + if FileManager.default.fileExists(atPath: originalURL.path) { + try FileManager.default.removeItem(at: originalURL) + } + } + + return SaveResult(filename: targetFilename, fileURL: targetURL) + } + + public func delete(_ note: Note, from directory: URL) throws { + let fileURL = directory.appendingPathComponent(note.filename) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return + } + + try FileManager.default.removeItem(at: fileURL) + } + + private func loadRequiredNote(from url: URL) throws -> Note { + guard url.pathExtension == "md" else { + throw NoteError.fileSystemError("Unsupported file type: \(url.pathExtension)") + } + + let content = try String(contentsOf: url, encoding: .utf8) + let title = url.deletingPathExtension().lastPathComponent + let resourceValues = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + let modifiedAt = resourceValues?.contentModificationDate ?? Date() + let createdAt = resourceValues?.creationDate ?? modifiedAt + + return Note.fromSerializedContent( + content, + fallbackTitle: title, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } +} diff --git a/Packages/ZettelKit/Sources/ZettelKit/TagParser.swift b/Packages/ZettelKit/Sources/ZettelKit/TagParser.swift index c90e575..ef491b9 100644 --- a/Packages/ZettelKit/Sources/ZettelKit/TagParser.swift +++ b/Packages/ZettelKit/Sources/ZettelKit/TagParser.swift @@ -11,8 +11,7 @@ import Foundation public final class TagParser: Sendable { /// Regex pattern to match standalone hashtags: start of text or whitespace, then # and valid tag characters private static let hashtagPattern = #"(?| ", content: "Body") + #expect(note.filename == "Daily-.md") + } + + @Test + func whitespaceOnlyTitleFallsBackToUntitled() { + let note = Note(title: " ", content: "") + #expect(note.filename == "Untitled.md") + } + + @Test + func autoGeneratedTitleUsesConfiguredTemplateWhenTitleIsEmpty() { + let defaults = makeIsolatedDefaults() + let manager = DefaultTitleTemplateManager(userDefaults: defaults, storageKey: "autoTitle") + manager.saveTemplate("{{shortDate}}") + + var note = Note(title: "", content: "") + note.createdAt = makeDate(year: 2025, month: 10, day: 19, hour: 8, minute: 0, second: 0) + + #expect(manager.generateTitle(for: note.createdAt) == "2025-10-19") + } + + @Test + func uniqueFilenameAddsCounterForCollisions() throws { + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let originalURL = tempDirectory.url.appendingPathComponent("Collision.md") + try "Existing".write(to: originalURL, atomically: true, encoding: .utf8) + + let note = Note(title: "Collision", content: "New") + #expect(note.generateUniqueFilename(in: tempDirectory.url) == "Collision_1.md") + } + + @Test + func contentPreviewRespectsLineLimit() { + let note = Note(title: "Preview", content: "One\nTwo\nThree\nFour") + #expect(note.contentPreview(maxLines: 2) == "One\nTwo") + #expect(note.contentPreview == "One\nTwo\nThree") + } + + @Test + func deserializationPreservesFallbackTitleAndTimestamps() { + let createdAt = makeDate(year: 2024, month: 12, day: 1, hour: 9, minute: 0, second: 0) + let modifiedAt = makeDate(year: 2024, month: 12, day: 2, hour: 10, minute: 0, second: 0) + + let note = Note.fromSerializedContent( + "#tagged content", + fallbackTitle: "Imported", + createdAt: createdAt, + modifiedAt: modifiedAt + ) + + #expect(note.title == "Imported") + #expect(note.createdAt == createdAt) + #expect(note.modifiedAt == modifiedAt) + } + + @Test + func validationAndSanitizationCatchOversizedContent() { + let overlongTitle = String(repeating: "T", count: ValidationConstants.maxNoteTitleLength + 5) + let overlongContent = String(repeating: "C", count: ValidationConstants.maxNoteContentLength + 10) + var note = Note(title: overlongTitle, content: overlongContent) + + let errors = note.validate() + #expect(errors.contains { if case .titleTooLong = $0 { true } else { false } }) + #expect(errors.contains { if case .contentTooLong = $0 { true } else { false } }) + + note.sanitize() + #expect(note.title.count == ValidationConstants.maxNoteTitleLength) + #expect(note.content.count == ValidationConstants.maxNoteContentLength) + } + + @Test + func tagParsingNormalizesAndDeduplicatesAcrossTitleAndBody() { + let note = Note(title: "#Swift", content: "Working on #swift and #Testing") + #expect(note.extractedTags == ["swift", "testing"]) + } + + @Test + func hashtagFactoryAcceptsValidNamesAndRejectsInvalidCharacters() { + #expect(Tag.fromHashtag("#valid_tag")?.id == "valid_tag") + #expect(Tag.fromHashtag("#Swift")?.displayName == "Swift") + #expect(Tag.fromHashtag("#not-valid") == nil) + } + + @Test + func findHashtagAtPositionHandlesValidAndInvalidInput() { + let text = "hello #sw" + let validResult = TagParser.findHashtagAtPosition(text, position: text.count) + #expect(validResult?.partial == "sw") + #expect(validResult?.range.location == 6) + + #expect(TagParser.findHashtagAtPosition("hello #sw if", position: 12) == nil) + #expect(TagParser.findHashtagAtPosition("hello #sw!", position: 10) == nil) + #expect(TagParser.findHashtagAtPosition("hello", position: 20) == nil) + } + + @Test + func replaceHashtagOnlyReplacesSelectedRange() { + let text = "Work on #sw today" + let range = NSRange(location: 8, length: 3) + #expect(TagParser.replaceHashtag(in: text, range: range, with: "swift") == "Work on #swift today") + } +} diff --git a/Packages/ZettelKit/Tests/ZettelKitTests/TemplateAndRepositoryTests.swift b/Packages/ZettelKit/Tests/ZettelKitTests/TemplateAndRepositoryTests.swift new file mode 100644 index 0000000..736c32c --- /dev/null +++ b/Packages/ZettelKit/Tests/ZettelKitTests/TemplateAndRepositoryTests.swift @@ -0,0 +1,123 @@ +import Foundation +import Testing +@testable import ZettelKit + +struct TemplateAndRepositoryTests { + @Test + func templateRenderingSupportsBuiltInPlaceholders() { + let defaults = makeIsolatedDefaults() + let manager = DefaultTitleTemplateManager( + userDefaults: defaults, + storageKey: "titleTemplate", + timeZone: TimeZone(identifier: "Europe/Berlin")! + ) + let date = makeDate(year: 2025, month: 10, day: 19, hour: 8, minute: 5, second: 4) + + manager.saveTemplate("{{weekday}} {{date}} {{shortDate}} {{time}}") + + #expect(manager.generateTitle(for: date) == "Sunday 19 Oct 2025 2025-10-19 10-05-04") + } + + @Test + func blankTemplateFallsBackToLegacyTitleFormat() { + let defaults = makeIsolatedDefaults() + let manager = DefaultTitleTemplateManager( + userDefaults: defaults, + storageKey: "blankTemplate", + timeZone: TimeZone(identifier: "Europe/Berlin")! + ) + let date = makeDate(year: 2025, month: 10, day: 19, hour: 8, minute: 5, second: 4) + + manager.saveTemplate(" ") + + #expect(manager.currentTemplate() == manager.fallbackTemplate) + #expect(manager.generateTitle(for: date) == "2025-10-19 – 10-05-04") + } + + @Test + func templatePersistenceCanBeSavedAndCleared() { + let defaults = makeIsolatedDefaults() + let manager = DefaultTitleTemplateManager(userDefaults: defaults, storageKey: "persistedTemplate") + + manager.saveTemplate("{{weekday}}") + #expect(manager.savedTemplate() == "{{weekday}}") + + manager.saveTemplate("") + #expect(manager.savedTemplate() == nil) + } + + @Test + func repositoryLoadsOnlyMarkdownFilesAndPreservesMetadata() throws { + let repository = NoteFileRepository() + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let noteURL = tempDirectory.url.appendingPathComponent("Loaded.md") + let createdAt = makeDate(year: 2024, month: 11, day: 1, hour: 7, minute: 0, second: 0) + let modifiedAt = makeDate(year: 2024, month: 11, day: 2, hour: 7, minute: 0, second: 0) + + try "Body #tag".write(to: noteURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.creationDate: createdAt, .modificationDate: modifiedAt], + ofItemAtPath: noteURL.path + ) + try "ignore me".write( + to: tempDirectory.url.appendingPathComponent("Ignored.txt"), + atomically: true, + encoding: .utf8 + ) + + let notes = try repository.loadNotes(in: tempDirectory.url) + + #expect(notes.count == 1) + #expect(notes.first?.title == "Loaded") + #expect(notes.first?.createdAt == createdAt) + #expect(notes.first?.modifiedAt == modifiedAt) + } + + @Test + func repositorySaveWritesNoteAndReturnsFinalFilename() throws { + let repository = NoteFileRepository() + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let note = Note(title: "Repository Save", content: "Body") + let result = try repository.save(note, in: tempDirectory.url) + + #expect(result.filename == "Repository Save.md") + #expect(FileManager.default.fileExists(atPath: result.fileURL.path)) + #expect(try String(contentsOf: result.fileURL, encoding: .utf8) == "Body") + } + + @Test + func repositoryRenameDeletesOriginalAfterSuccessfulSave() throws { + let repository = NoteFileRepository() + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let originalURL = tempDirectory.url.appendingPathComponent("Original.md") + try "Old".write(to: originalURL, atomically: true, encoding: .utf8) + + let renamed = Note(title: "Renamed", content: "Updated") + let result = try repository.save(renamed, in: tempDirectory.url, originalFilename: "Original.md") + + #expect(result.filename == "Renamed.md") + #expect(!FileManager.default.fileExists(atPath: originalURL.path)) + #expect(FileManager.default.fileExists(atPath: tempDirectory.url.appendingPathComponent("Renamed.md").path)) + } + + @Test + func repositoryDeleteRemovesExistingMarkdownFile() throws { + let repository = NoteFileRepository() + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let note = Note(title: "Delete Me", content: "") + let fileURL = tempDirectory.url.appendingPathComponent(note.filename) + try "".write(to: fileURL, atomically: true, encoding: .utf8) + + try repository.delete(note, from: tempDirectory.url) + + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } +} diff --git a/Packages/ZettelKit/Tests/ZettelKitTests/TestSupport.swift b/Packages/ZettelKit/Tests/ZettelKitTests/TestSupport.swift new file mode 100644 index 0000000..3c0fe23 --- /dev/null +++ b/Packages/ZettelKit/Tests/ZettelKitTests/TestSupport.swift @@ -0,0 +1,42 @@ +import Foundation + +struct TemporaryDirectory { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } + + func cleanup() { + try? FileManager.default.removeItem(at: url) + } +} + +func makeDate( + year: Int = 2026, + month: Int = 3, + day: Int = 9, + hour: Int = 12, + minute: Int = 34, + second: Int = 56 +) -> Date { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(secondsFromGMT: 0) + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = second + return components.date! +} + +func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "ZettelKitTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} diff --git a/README.md b/README.md index 1fabced..24b3159 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,21 @@ Or run the configuration and build separately: - `./build.sh ` - Configure and build for the specified platform - `./clean.sh` - Reset project configuration to clean state +## Testing + +Run package tests without app configuration: + +```bash +swift test --package-path Packages/ZettelKit +``` + +Run app-target tests after `./configure.sh`: + +```bash +xcodebuild test -project Zettel.xcodeproj -scheme ZettelMac -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO +xcodebuild test -project Zettel.xcodeproj -scheme Zettel -destination 'platform=iOS Simulator,name=iPhone 16' CODE_SIGNING_ALLOWED=NO +``` + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution guidelines. diff --git a/Zettel.xcodeproj/project.pbxproj b/Zettel.xcodeproj/project.pbxproj index cc548db..4b27c86 100644 --- a/Zettel.xcodeproj/project.pbxproj +++ b/Zettel.xcodeproj/project.pbxproj @@ -7,14 +7,46 @@ objects = { /* Begin PBXBuildFile section */ + 03CDF98D51107E82E94104B3 /* ChangelogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D872536B4F6FA5D1E2CEE29 /* ChangelogManagerTests.swift */; }; + 148510A4C0DC13EB4BE9AC24 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDAF2D88C05FC49730ED0CDB /* Foundation.framework */; }; 4BB769962F47B4CF006075B1 /* ZettelKit in Frameworks */ = {isa = PBXBuildFile; productRef = CE005BFF1E3238B24810B42F /* ZettelKit */; }; + 809BD1513AA4665F2AC46FFE /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F309D07E5A49AC8761C88C52 /* TestSupport.swift */; }; + 8E42EF412618517CBD344751 /* TagStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBC5151DCC46A06CCD09405F /* TagStoreTests.swift */; }; 9919791BA4923E353DD9080A /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8723AD39BFE4641DD8E888B /* Cocoa.framework */; }; + DAD38E88283E89BE88723785 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8723AD39BFE4641DD8E888B /* Cocoa.framework */; }; + DEC01668F08939B9D297C06B /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB3357F551F5276B36DEB28 /* TestSupport.swift */; }; + F10B49A8D770B34019B496AD /* MacNoteStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBD6C4343891DDA08EDE158 /* MacNoteStoreTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 7AC9C9522C8D3AEC03D130D9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9ED9FF9A2E09D9AF0000CCCD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 265B02BCDB83B24B5A542C82; + remoteInfo = ZettelMac; + }; + F2324013284DE73D54E9B51A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9ED9FF9A2E09D9AF0000CCCD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9ED9FFA12E09D9AF0000CCCD; + remoteInfo = Zettel; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 2CBD6C4343891DDA08EDE158 /* MacNoteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MacNoteStoreTests.swift; sourceTree = ""; }; + 2D872536B4F6FA5D1E2CEE29 /* ChangelogManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChangelogManagerTests.swift; sourceTree = ""; }; + 3B7956C0137F2709CB35D08E /* ZettelMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ZettelMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3EB3357F551F5276B36DEB28 /* TestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; 9ED9FFA22E09D9AF0000CCCD /* Zettel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zettel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A87D8161A3FAF30630573C10 /* ZettelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ZettelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + BBC5151DCC46A06CCD09405F /* TagStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagStoreTests.swift; sourceTree = ""; }; D9F21730384B9E76C684CD7C /* Zettel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zettel.app; sourceTree = BUILT_PRODUCTS_DIR; }; E8723AD39BFE4641DD8E888B /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; + EDAF2D88C05FC49730ED0CDB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + F309D07E5A49AC8761C88C52 /* TestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -54,6 +86,22 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 0A14333874C9B908CE93ED86 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DAD38E88283E89BE88723785 /* Cocoa.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4107B0D0040EEF96F37B852D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 148510A4C0DC13EB4BE9AC24 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9ED9FF9F2E09D9AF0000CCCD /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -77,10 +125,29 @@ isa = PBXGroup; children = ( BE8241F5BFF75FB72B00DF9C /* OS X */, + 652921153D0F6C21661F2589 /* iOS */, ); name = Frameworks; sourceTree = ""; }; + 17BDE4ADE8F893CC0675B4E0 /* ZettelMacTests */ = { + isa = PBXGroup; + children = ( + 2CBD6C4343891DDA08EDE158 /* MacNoteStoreTests.swift */, + 3EB3357F551F5276B36DEB28 /* TestSupport.swift */, + ); + name = ZettelMacTests; + path = ZettelMacTests; + sourceTree = ""; + }; + 652921153D0F6C21661F2589 /* iOS */ = { + isa = PBXGroup; + children = ( + EDAF2D88C05FC49730ED0CDB /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 9ED9FF992E09D9AF0000CCCD = { isa = PBXGroup; children = ( @@ -88,6 +155,8 @@ 9ED9FFA32E09D9AF0000CCCD /* Products */, 06822BD6ADE5F429388586F1 /* Frameworks */, 8A9AF4FCF7B1540CF84A1C9E /* ZettelMac */, + A393DF4F986D23CBCFD794B8 /* ZettelTests */, + 17BDE4ADE8F893CC0675B4E0 /* ZettelMacTests */, ); sourceTree = ""; }; @@ -96,10 +165,23 @@ children = ( 9ED9FFA22E09D9AF0000CCCD /* Zettel.app */, D9F21730384B9E76C684CD7C /* Zettel.app */, + A87D8161A3FAF30630573C10 /* ZettelTests.xctest */, + 3B7956C0137F2709CB35D08E /* ZettelMacTests.xctest */, ); name = Products; sourceTree = ""; }; + A393DF4F986D23CBCFD794B8 /* ZettelTests */ = { + isa = PBXGroup; + children = ( + 2D872536B4F6FA5D1E2CEE29 /* ChangelogManagerTests.swift */, + BBC5151DCC46A06CCD09405F /* TagStoreTests.swift */, + F309D07E5A49AC8761C88C52 /* TestSupport.swift */, + ); + name = ZettelTests; + path = ZettelTests; + sourceTree = ""; + }; BE8241F5BFF75FB72B00DF9C /* OS X */ = { isa = PBXGroup; children = ( @@ -134,6 +216,24 @@ productReference = D9F21730384B9E76C684CD7C /* Zettel.app */; productType = "com.apple.product-type.application"; }; + 5721BCFF635BFA1E6D16E75B /* ZettelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3BA09DB0DCCEB51C6985BB84 /* Build configuration list for PBXNativeTarget "ZettelTests" */; + buildPhases = ( + 87A6662729F4DB46FCF5AC63 /* Sources */, + 4107B0D0040EEF96F37B852D /* Frameworks */, + EC0C5EF4CC01D600AB8F7A81 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B85DD79C95DA06D89E0E611A /* PBXTargetDependency */, + ); + name = ZettelTests; + productName = ZettelTests; + productReference = A87D8161A3FAF30630573C10 /* ZettelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 9ED9FFA12E09D9AF0000CCCD /* Zettel */ = { isa = PBXNativeTarget; buildConfigurationList = 9ED9FFC32E09D9B00000CCCD /* Build configuration list for PBXNativeTarget "Zettel" */; @@ -154,6 +254,24 @@ productReference = 9ED9FFA22E09D9AF0000CCCD /* Zettel.app */; productType = "com.apple.product-type.application"; }; + DBED6DC964BC5D8EEEC07C91 /* ZettelMacTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BECB03737512764A029D8105 /* Build configuration list for PBXNativeTarget "ZettelMacTests" */; + buildPhases = ( + 4C765E02DE1308C3F665A51C /* Sources */, + 0A14333874C9B908CE93ED86 /* Frameworks */, + D2D0403DAB04699C9AAAEFD5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 436254CD7BC6A1FF29336301 /* PBXTargetDependency */, + ); + name = ZettelMacTests; + productName = ZettelMacTests; + productReference = 3B7956C0137F2709CB35D08E /* ZettelMacTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -181,7 +299,7 @@ mainGroup = 9ED9FF992E09D9AF0000CCCD; minimizedProjectReferenceProxies = 1; packageReferences = ( - ED3041C0714E7176580832BD /* XCLocalSwiftPackageReference "Packages/ZettelKit" */, + ED3041C0714E7176580832BD /* XCLocalSwiftPackageReference "ZettelKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 9ED9FFA32E09D9AF0000CCCD /* Products */; @@ -190,6 +308,8 @@ targets = ( 9ED9FFA12E09D9AF0000CCCD /* Zettel */, 265B02BCDB83B24B5A542C82 /* ZettelMac */, + 5721BCFF635BFA1E6D16E75B /* ZettelTests */, + DBED6DC964BC5D8EEEC07C91 /* ZettelMacTests */, ); }; /* End PBXProject section */ @@ -209,9 +329,42 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D2D0403DAB04699C9AAAEFD5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EC0C5EF4CC01D600AB8F7A81 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4C765E02DE1308C3F665A51C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F10B49A8D770B34019B496AD /* MacNoteStoreTests.swift in Sources */, + DEC01668F08939B9D297C06B /* TestSupport.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 87A6662729F4DB46FCF5AC63 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 03CDF98D51107E82E94104B3 /* ChangelogManagerTests.swift in Sources */, + 8E42EF412618517CBD344751 /* TagStoreTests.swift in Sources */, + 809BD1513AA4665F2AC46FFE /* TestSupport.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 94DC1E8B6B2B827D63D000BF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -228,7 +381,61 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 436254CD7BC6A1FF29336301 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ZettelMac; + target = 265B02BCDB83B24B5A542C82 /* ZettelMac */; + targetProxy = 7AC9C9522C8D3AEC03D130D9 /* PBXContainerItemProxy */; + }; + B85DD79C95DA06D89E0E611A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Zettel; + target = 9ED9FFA12E09D9AF0000CCCD /* Zettel */; + targetProxy = F2324013284DE73D54E9B51A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 075534F03DEE17553EF01CA5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = ""; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_MODULE_NAME = ZettelTests; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Zettel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Zettel"; + TEST_TARGET_NAME = Zettel; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0BD3CEBB77859E3F2425AFBC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = ""; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_MODULE_NAME = ZettelMacTests; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Zettel.app/Contents/MacOS/Zettel"; + TEST_TARGET_NAME = ZettelMac; + }; + name = Debug; + }; 0D26BE367FBB2E38A1CE731A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -473,6 +680,26 @@ }; name = Release; }; + BFB278E67755389FDF6CEEE9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = ""; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_MODULE_NAME = ZettelTests; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Zettel.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Zettel"; + TEST_TARGET_NAME = Zettel; + }; + name = Debug; + }; F639BA6086368C486748005C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -509,9 +736,36 @@ }; name = Release; }; + FF4F4C2F23FA2C2FA40FC06A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = ""; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_MODULE_NAME = ZettelMacTests; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Zettel.app/Contents/MacOS/Zettel"; + TEST_TARGET_NAME = ZettelMac; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 3BA09DB0DCCEB51C6985BB84 /* Build configuration list for PBXNativeTarget "ZettelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 075534F03DEE17553EF01CA5 /* Release */, + BFB278E67755389FDF6CEEE9 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 3D9218D505FF7FC14C2F04F0 /* Build configuration list for PBXNativeTarget "ZettelMac" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -539,10 +793,19 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BECB03737512764A029D8105 /* Build configuration list for PBXNativeTarget "ZettelMacTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FF4F4C2F23FA2C2FA40FC06A /* Release */, + 0BD3CEBB77859E3F2425AFBC /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - ED3041C0714E7176580832BD /* XCLocalSwiftPackageReference "Packages/ZettelKit" */ = { + ED3041C0714E7176580832BD /* XCLocalSwiftPackageReference "ZettelKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/ZettelKit; }; @@ -551,7 +814,7 @@ /* Begin XCSwiftPackageProductDependency section */ CE005BFF1E3238B24810B42F /* ZettelKit */ = { isa = XCSwiftPackageProductDependency; - package = ED3041C0714E7176580832BD /* XCLocalSwiftPackageReference "Packages/ZettelKit" */; + package = ED3041C0714E7176580832BD /* XCLocalSwiftPackageReference "ZettelKit" */; productName = ZettelKit; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Zettel.xcodeproj/xcshareddata/xcschemes/Zettel.xcscheme b/Zettel.xcodeproj/xcshareddata/xcschemes/Zettel.xcscheme index 1211631..ba979bd 100644 --- a/Zettel.xcodeproj/xcshareddata/xcschemes/Zettel.xcscheme +++ b/Zettel.xcodeproj/xcshareddata/xcschemes/Zettel.xcscheme @@ -35,23 +35,12 @@ parallelizable = "YES"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Zettel/Stores/TagStore.swift b/Zettel/Stores/TagStore.swift index a1c518c..7a653e1 100644 --- a/Zettel/Stores/TagStore.swift +++ b/Zettel/Stores/TagStore.swift @@ -16,6 +16,7 @@ class TagStore: ObservableObject { // Debouncing support private var updateTimer: Timer? + private var updateTask: Task? private let updateDelay: TimeInterval = CacheConstants.tagUpdateDelay // Application lifecycle support @@ -44,6 +45,7 @@ class TagStore: ObservableObject { } deinit { + updateTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -87,21 +89,48 @@ class TagStore: ObservableObject { func updateTagsImmediately(from notes: [Note]) { // Capture light-weight copies for background work let lightweightNotes = notes.map { (title: $0.title, content: $0.content) } - - Task.detached { - // Compute in background + + updateTask?.cancel() + updateTask = Task { [weak self] in + let result = await Self.buildTagIndex(from: lightweightNotes) + guard let self, !Task.isCancelled else { return } + self.tagUsageCounts = result.tagCounts + self.tagsByName = result.tagsByName + self.sortedTags = result.sortedTags + } + } + + /// Legacy method for backward compatibility + func updateTags(from notes: [Note]) { + updateTagsImmediately(from: notes) + } + + func waitForPendingUpdates() async { + await updateTask?.value + } + + /// Single-scan extractor that returns mapping normalized->display and the set of unique normalized tags + nonisolated private static func extractNormalizedAndDisplay(from text: String) -> ([String: String], Set) { + // moved to TagParser to avoid @MainActor isolation issues + return TagParser.extractNormalizedAndDisplay(from: text) + } + + private static func buildTagIndex(from notes: [(title: String, content: String)]) async -> ( + tagCounts: [String: Int], + tagsByName: [String: Tag], + sortedTags: [Tag] + ) { + await Task.detached { var tagCounts: [String: Int] = [:] var tagDisplayNames: [String: String] = [:] - - for note in lightweightNotes { - // Single-pass extraction over title and content + + for note in notes { var noteText = note.title noteText.append(" ") noteText.append(note.content) - - // Use TagParser once per note text for both normalized and display names - let (normalizedToDisplay, uniqueNormalized) = TagParser.extractNormalizedAndDisplay(from: noteText) - + + let (normalizedToDisplay, uniqueNormalized) = extractNormalizedAndDisplay(from: noteText) + for tagName in uniqueNormalized { tagCounts[tagName, default: 0] += 1 if tagDisplayNames[tagName] == nil { @@ -109,8 +138,7 @@ class TagStore: ObservableObject { } } } - - // Build Tag objects + var newTagsByName: [String: Tag] = [:] for (normalizedName, count) in tagCounts { let displayName = tagDisplayNames[normalizedName] ?? normalizedName @@ -118,33 +146,14 @@ class TagStore: ObservableObject { tag.usageCount = count newTagsByName[normalizedName] = tag } - - // Prepare immutable values to avoid capturing mutable vars across await - let finalTagCounts = tagCounts - let finalTagsByName = newTagsByName - let finalSortedTags = Array(finalTagsByName.values).sorted { t1, t2 in + + let finalSortedTags = Array(newTagsByName.values).sorted { t1, t2 in if t1.usageCount != t2.usageCount { return t1.usageCount > t2.usageCount } - return t1.displayName < t2.displayName - } - - // Publish on main - await MainActor.run { - self.tagUsageCounts = finalTagCounts - self.tagsByName = finalTagsByName - self.sortedTags = finalSortedTags + return t1.id < t2.id } - } - } - - /// Legacy method for backward compatibility - func updateTags(from notes: [Note]) { - updateTagsImmediately(from: notes) - } - - /// Single-scan extractor that returns mapping normalized->display and the set of unique normalized tags - private static func extractNormalizedAndDisplay(from text: String) -> ([String: String], Set) { - // moved to TagParser to avoid @MainActor isolation issues - return TagParser.extractNormalizedAndDisplay(from: text) + + return (tagCounts, newTagsByName, finalSortedTags) + }.value } /// Helper to find original case of a tag in text (no longer used) diff --git a/Zettel/Utilities/ChangelogManager.swift b/Zettel/Utilities/ChangelogManager.swift index 59c99d4..b1a43cf 100644 --- a/Zettel/Utilities/ChangelogManager.swift +++ b/Zettel/Utilities/ChangelogManager.swift @@ -81,10 +81,14 @@ class ChangelogManager: ObservableObject { static let shared = ChangelogManager() /// Key for storing the last seen app version in UserDefaults - private let lastSeenVersionKey = "lastSeenAppVersion" + private let lastSeenVersionKey: String /// Key used by NoteStore to track if the user has launched before (not a first-time user) - private let hasLaunchedBeforeKey = "hasLaunchedBefore" + private let hasLaunchedBeforeKey: String + + private let userDefaults: UserDefaults + private let currentVersionProvider: () -> AppVersion? + private let changelogDataProvider: () -> [(version: String, title: String, content: String)] /// All available changelog entries, sorted by version (newest first) @Published private(set) var changelogEntries: [ChangelogEntry] = [] @@ -92,7 +96,18 @@ class ChangelogManager: ObservableObject { /// The changelog entry to display (if any) @Published private(set) var pendingChangelog: ChangelogEntry? - private init() { + init( + userDefaults: UserDefaults = .standard, + lastSeenVersionKey: String = "lastSeenAppVersion", + hasLaunchedBeforeKey: String = "hasLaunchedBefore", + currentVersionProvider: @escaping () -> AppVersion? = { AppVersion.current }, + changelogDataProvider: @escaping () -> [(version: String, title: String, content: String)] = { ChangelogData.entries } + ) { + self.userDefaults = userDefaults + self.lastSeenVersionKey = lastSeenVersionKey + self.hasLaunchedBeforeKey = hasLaunchedBeforeKey + self.currentVersionProvider = currentVersionProvider + self.changelogDataProvider = changelogDataProvider loadChangelogEntries() } @@ -105,7 +120,7 @@ class ChangelogManager: ObservableObject { #endif // Load from static Swift data (most reliable) - for data in ChangelogData.entries { + for data in changelogDataProvider() { if let version = AppVersion(from: data.version) { let entry = ChangelogEntry( version: version, @@ -135,7 +150,7 @@ class ChangelogManager: ObservableObject { print("[Changelog] Available changelogs: \(changelogEntries.map { $0.version.displayString })") #endif - guard let currentVersion = AppVersion.current else { + guard let currentVersion = currentVersionProvider() else { #if DEBUG print("[Changelog] ERROR: Could not get current app version from bundle") #endif @@ -162,7 +177,7 @@ class ChangelogManager: ObservableObject { #endif // Check if user has launched before (not a first-time user showing the tutorial) - let hasLaunchedBefore = UserDefaults.standard.bool(forKey: hasLaunchedBeforeKey) + let hasLaunchedBefore = userDefaults.bool(forKey: hasLaunchedBeforeKey) #if DEBUG print("[Changelog] Has launched before: \(hasLaunchedBefore)") @@ -192,7 +207,7 @@ class ChangelogManager: ObservableObject { /// Called when user dismisses the changelog func dismissChangelog() { - guard let currentVersion = AppVersion.current else { + guard let currentVersion = currentVersionProvider() else { return } setLastSeenVersion(currentVersion) @@ -201,7 +216,7 @@ class ChangelogManager: ObservableObject { /// Gets the last seen app version from UserDefaults private func getLastSeenVersion() -> AppVersion? { - guard let versionString = UserDefaults.standard.string(forKey: lastSeenVersionKey) else { + guard let versionString = userDefaults.string(forKey: lastSeenVersionKey) else { return nil } return AppVersion(from: versionString) @@ -209,12 +224,12 @@ class ChangelogManager: ObservableObject { /// Saves the last seen app version to UserDefaults private func setLastSeenVersion(_ version: AppVersion) { - UserDefaults.standard.set(version.description, forKey: lastSeenVersionKey) + userDefaults.set(version.description, forKey: lastSeenVersionKey) } /// Returns the changelog entry for the current app version (if available) func getChangelogForCurrentVersion() -> ChangelogEntry? { - guard let currentVersion = AppVersion.current else { + guard let currentVersion = currentVersionProvider() else { return nil } return changelogEntries.first(where: { $0.version == currentVersion }) @@ -222,7 +237,7 @@ class ChangelogManager: ObservableObject { /// For debugging: clears the last seen version to force changelog display func resetLastSeenVersion() { - UserDefaults.standard.removeObject(forKey: lastSeenVersionKey) + userDefaults.removeObject(forKey: lastSeenVersionKey) pendingChangelog = nil checkForNewChangelog() } diff --git a/ZettelMac/AppDelegate.swift b/ZettelMac/AppDelegate.swift index c1f0519..0921fab 100644 --- a/ZettelMac/AppDelegate.swift +++ b/ZettelMac/AppDelegate.swift @@ -13,8 +13,13 @@ import ZettelKit final class ZettelAppDelegate: NSObject, NSApplicationDelegate { private let hasLaunchedBeforeKey = "hasLaunchedBefore" + private var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } func applicationDidFinishLaunching(_ notification: Notification) { + guard !isRunningTests else { return } + // Show welcome note on first launch, otherwise restore the last opened note if isFirstLaunch() { let welcomeNote = createWelcomeNote() @@ -64,6 +69,8 @@ final class ZettelAppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + guard !isRunningTests else { return true } + if !flag { // No visible windows — restore last note or create a new one let restoredNote = restoreLastOpenedNote() @@ -80,11 +87,15 @@ final class ZettelAppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + guard !isRunningTests else { return } + // Flush all pending saves before quitting ZettelWindowManager.shared.saveAllWindows() } func application(_ sender: NSApplication, openFile filename: String) -> Bool { + guard !isRunningTests else { return false } + let url = URL(fileURLWithPath: filename) guard url.pathExtension == "md" else { return false } @@ -97,6 +108,8 @@ final class ZettelAppDelegate: NSObject, NSApplicationDelegate { } func application(_ application: NSApplication, open urls: [URL]) { + guard !isRunningTests else { return } + for url in urls { guard url.pathExtension == "md" else { continue } Task { @MainActor in diff --git a/ZettelMac/Stores/MacNoteStore.swift b/ZettelMac/Stores/MacNoteStore.swift index f042e77..9e9fee9 100644 --- a/ZettelMac/Stores/MacNoteStore.swift +++ b/ZettelMac/Stores/MacNoteStore.swift @@ -44,6 +44,10 @@ public final class MacNoteStore: NSObject, NSFilePresenter { // MARK: - Private private let storageDirectoryBookmarkKey = "macStorageDirectoryBookmark" + private let userDefaults: UserDefaults + private let notificationCenter: NotificationCenter + private let repository: NoteFileRepository + private let monitorsFileSystem: Bool private var refreshDebounceTask: Task? private var activationObserver: (any NSObjectProtocol)? @@ -66,32 +70,47 @@ public final class MacNoteStore: NSObject, NSFilePresenter { // MARK: - Init - private override init() { + init( + storageDirectory: URL? = nil, + userDefaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default, + repository: NoteFileRepository = NoteFileRepository(), + shouldStartMonitoringFileSystem: Bool = true, + observeActivation: Bool = true + ) { // Default to ~/Documents/Zettel let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory - self.storageDirectory = documentsURL.appendingPathComponent("Zettel", isDirectory: true) + self.storageDirectory = storageDirectory ?? documentsURL.appendingPathComponent("Zettel", isDirectory: true) + self.userDefaults = userDefaults + self.notificationCenter = notificationCenter + self.repository = repository + self.monitorsFileSystem = shouldStartMonitoringFileSystem super.init() // Restore saved directory - if let restored = restoreStorageDirectory() { + if storageDirectory == nil, let restored = restoreStorageDirectory() { self.storageDirectory = restored } self._presentedItemURL = self.storageDirectory createStorageDirectoryIfNeeded() - startMonitoringFileSystem() + if shouldStartMonitoringFileSystem { + startMonitoringFileSystem() + } // Reload notes when app regains focus (catches external file changes) - activationObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.loadAllNotes() + if observeActivation { + activationObserver = notificationCenter.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.loadAllNotes() + } } } } @@ -113,6 +132,7 @@ public final class MacNoteStore: NSObject, NSFilePresenter { } let directory = storageDirectory + let repository = self.repository let notes = await Task.detached { () -> [Note] in let fm = FileManager.default guard let files = try? fm.contentsOfDirectory( @@ -149,8 +169,7 @@ public final class MacNoteStore: NSObject, NSFilePresenter { } if isDownloaded { - if let content = try? String(contentsOf: url, encoding: .utf8) { - let note = Note.fromSerializedContent(content, fallbackTitle: title, createdAt: createdAt, modifiedAt: modifiedAt) + if let note = repository.loadNote(from: url) { result.append(note) } } else { @@ -188,44 +207,37 @@ public final class MacNoteStore: NSObject, NSFilePresenter { } } - // Delete old file if renamed - if let original = originalFilename, original != note.filename { - let oldURL = storageDirectory.appendingPathComponent(original) - try? FileManager.default.removeItem(at: oldURL) - } - - // Determine the filename: reuse original if not renamed, otherwise generate unique - let targetFilename: String - if let original = originalFilename, original == note.filename { - targetFilename = original - } else { - targetFilename = note.generateUniqueFilename(in: storageDirectory) - } - - // Track old file deletion as "ours" to suppress echo from file monitor - if let original = originalFilename, original != note.filename { - recentlyOwnedFiles[original] = Date() - } - - let fileURL = storageDirectory.appendingPathComponent(targetFilename) - do { - try note.serializedContent.write(to: fileURL, atomically: true, encoding: .utf8) + let saveResult = try repository.save(note, in: storageDirectory, originalFilename: originalFilename) // Track this save to suppress echo from file monitor - recentlyOwnedFiles[targetFilename] = Date() + recentlyOwnedFiles[saveResult.filename] = Date() + if let originalFilename, originalFilename != saveResult.filename { + recentlyOwnedFiles[originalFilename] = Date() + } // Update allNotes cache - if let index = allNotes.firstIndex(where: { $0.id == targetFilename || $0.id == (originalFilename ?? "") }) { - var updated = note - // Ensure the note's title matches the filename we used - allNotes[index] = updated + if let index = allNotes.firstIndex(where: { $0.id == (originalFilename ?? note.id) || $0.id == saveResult.filename }) { + allNotes[index] = Note.fromSerializedContent( + note.serializedContent, + fallbackTitle: saveResult.fileURL.deletingPathExtension().lastPathComponent, + createdAt: allNotes[index].createdAt, + modifiedAt: note.modifiedAt + ) } else { // New note — add to front - allNotes.insert(note, at: 0) + let persistedNote = Note.fromSerializedContent( + note.serializedContent, + fallbackTitle: saveResult.fileURL.deletingPathExtension().lastPathComponent, + createdAt: note.createdAt, + modifiedAt: note.modifiedAt + ) + allNotes.insert(persistedNote, at: 0) } - return targetFilename + allNotes.sort { $0.modifiedAt > $1.modifiedAt } + + return saveResult.filename } catch { print("[MacNoteStore] Error saving note: \(error)") return nil @@ -241,16 +253,13 @@ public final class MacNoteStore: NSObject, NSFilePresenter { } } - let fileURL = storageDirectory.appendingPathComponent(note.filename) recentlyOwnedFiles[note.filename] = Date() - try? FileManager.default.trashItem(at: fileURL, resultingItemURL: nil) + try? repository.delete(note, from: storageDirectory) allNotes.removeAll { $0.id == note.id } } /// Load a single note from a file URL public func loadNoteFromFile(_ url: URL) -> Note? { - guard url.pathExtension == "md" else { return nil } - let didStartAccessing = url.startAccessingSecurityScopedResource() defer { if didStartAccessing { @@ -258,13 +267,7 @@ public final class MacNoteStore: NSObject, NSFilePresenter { } } - guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } - let title = url.deletingPathExtension().lastPathComponent - let resourceValues = try? url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]) - let modifiedAt = resourceValues?.contentModificationDate ?? Date() - let createdAt = resourceValues?.creationDate ?? modifiedAt - - return Note.fromSerializedContent(content, fallbackTitle: title, createdAt: createdAt, modifiedAt: modifiedAt) + return repository.loadNote(from: url) } // MARK: - Cloud File Support @@ -372,7 +375,9 @@ public final class MacNoteStore: NSObject, NSFilePresenter { /// Update storage directory public func updateStorageDirectory(_ newDirectory: URL) { - stopMonitoringFileSystem() + if monitorsFileSystem { + stopMonitoringFileSystem() + } let didStartAccessing = newDirectory.startAccessingSecurityScopedResource() defer { @@ -385,10 +390,12 @@ public final class MacNoteStore: NSObject, NSFilePresenter { self.storageDirectory = newDirectory self._presentedItemURL = newDirectory createStorageDirectoryIfNeeded() - startMonitoringFileSystem() + if monitorsFileSystem { + startMonitoringFileSystem() + } // Notify all windows so they can flush saves and reset - NotificationCenter.default.post(name: .storageDirectoryDidChange, object: nil) + notificationCenter.post(name: .storageDirectoryDidChange, object: nil) Task { await loadAllNotes() @@ -471,7 +478,7 @@ public final class MacNoteStore: NSObject, NSFilePresenter { let title = url.deletingPathExtension().lastPathComponent await MainActor.run { - NotificationCenter.default.post( + self.notificationCenter.post( name: .noteFileDidChangeOnDisk, object: nil, userInfo: [ @@ -495,7 +502,7 @@ public final class MacNoteStore: NSObject, NSFilePresenter { storageDirectory.stopAccessingSecurityScopedResource() } } - try? FileManager.default.createDirectory(at: storageDirectory, withIntermediateDirectories: true) + try? repository.createDirectoryIfNeeded(at: storageDirectory) } private func saveStorageDirectoryBookmark(_ url: URL) { @@ -505,14 +512,14 @@ public final class MacNoteStore: NSObject, NSFilePresenter { includingResourceValuesForKeys: nil, relativeTo: nil ) - UserDefaults.standard.set(bookmark, forKey: storageDirectoryBookmarkKey) + userDefaults.set(bookmark, forKey: storageDirectoryBookmarkKey) } catch { print("[MacNoteStore] Error saving bookmark: \(error)") } } private func restoreStorageDirectory() -> URL? { - guard let bookmark = UserDefaults.standard.data(forKey: storageDirectoryBookmarkKey) else { + guard let bookmark = userDefaults.data(forKey: storageDirectoryBookmarkKey) else { return nil } diff --git a/ZettelMacTests/MacNoteStoreTests.swift b/ZettelMacTests/MacNoteStoreTests.swift new file mode 100644 index 0000000..86e23e3 --- /dev/null +++ b/ZettelMacTests/MacNoteStoreTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +import ZettelKit +@testable import Zettel + +@MainActor +struct MacNoteStoreTests { + @Test + func saveNoteWritesFileAndUpdatesInMemoryCollection() throws { + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let store = MacNoteStore( + storageDirectory: tempDirectory.url, + userDefaults: makeIsolatedDefaults(), + notificationCenter: NotificationCenter(), + shouldStartMonitoringFileSystem: false, + observeActivation: false + ) + let note = Note(title: "Mac Save", content: "Body") + + let filename = store.saveNote(note) + + #expect(filename == "Mac Save.md") + #expect(store.allNotes.count == 1) + #expect(store.allNotes.first?.title == "Mac Save") + #expect(FileManager.default.fileExists(atPath: tempDirectory.url.appendingPathComponent("Mac Save.md").path)) + } + + @Test + func deleteNoteRemovesCachedNoteAndFile() throws { + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let store = MacNoteStore( + storageDirectory: tempDirectory.url, + userDefaults: makeIsolatedDefaults(), + notificationCenter: NotificationCenter(), + shouldStartMonitoringFileSystem: false, + observeActivation: false + ) + let note = Note(title: "Delete Mac", content: "") + _ = store.saveNote(note) + + store.deleteNote(store.allNotes[0]) + + #expect(store.allNotes.isEmpty) + #expect(!FileManager.default.fileExists(atPath: tempDirectory.url.appendingPathComponent("Delete Mac.md").path)) + } + + @Test + func updateStorageDirectoryPostsNotificationAndReloadsNotes() async throws { + let sourceDirectory = try TemporaryDirectory() + let targetDirectory = try TemporaryDirectory() + defer { + sourceDirectory.cleanup() + targetDirectory.cleanup() + } + + let noteURL = targetDirectory.url.appendingPathComponent("Loaded.md") + try "Body".write(to: noteURL, atomically: true, encoding: .utf8) + + let notificationCenter = NotificationCenter() + let store = MacNoteStore( + storageDirectory: sourceDirectory.url, + userDefaults: makeIsolatedDefaults(), + notificationCenter: notificationCenter, + shouldStartMonitoringFileSystem: false, + observeActivation: false + ) + + var didReceiveNotification = false + let observer = notificationCenter.addObserver( + forName: .storageDirectoryDidChange, + object: nil, + queue: nil + ) { _ in + didReceiveNotification = true + } + defer { notificationCenter.removeObserver(observer) } + + store.updateStorageDirectory(targetDirectory.url) + + for _ in 0..<20 where store.allNotes.isEmpty { + try await Task.sleep(for: .milliseconds(20)) + } + + #expect(didReceiveNotification) + #expect(store.storageDirectory == targetDirectory.url) + #expect(store.allNotes.map(\.title) == ["Loaded"]) + } + + @Test + func loadNoteFromFileIgnoresUnsupportedFiles() throws { + let tempDirectory = try TemporaryDirectory() + defer { tempDirectory.cleanup() } + + let textFile = tempDirectory.url.appendingPathComponent("Ignore.txt") + try "Nope".write(to: textFile, atomically: true, encoding: .utf8) + + let store = MacNoteStore( + storageDirectory: tempDirectory.url, + userDefaults: makeIsolatedDefaults(), + notificationCenter: NotificationCenter(), + shouldStartMonitoringFileSystem: false, + observeActivation: false + ) + + #expect(store.loadNoteFromFile(textFile) == nil) + } +} diff --git a/ZettelMacTests/TestSupport.swift b/ZettelMacTests/TestSupport.swift new file mode 100644 index 0000000..f9106b3 --- /dev/null +++ b/ZettelMacTests/TestSupport.swift @@ -0,0 +1,22 @@ +import Foundation + +struct TemporaryDirectory { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } + + func cleanup() { + try? FileManager.default.removeItem(at: url) + } +} + +func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "ZettelMacTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} diff --git a/ZettelTests/ChangelogManagerTests.swift b/ZettelTests/ChangelogManagerTests.swift new file mode 100644 index 0000000..e274385 --- /dev/null +++ b/ZettelTests/ChangelogManagerTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import Zettel + +@MainActor +struct ChangelogManagerTests { + @Test + func appVersionParsesAndComparesVersions() { + #expect(AppVersion(from: "v3.1") == AppVersion(major: 3, minor: 1)) + #expect(AppVersion(from: "3.10")! > AppVersion(from: "3.2")!) + #expect(AppVersion(from: "invalid") == nil) + } + + @Test + func changelogShowsForUpgradeWhenUserHasLaunchedBefore() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: "hasLaunchedBefore") + defaults.set("2.2", forKey: "lastSeenAppVersion") + + let manager = ChangelogManager( + userDefaults: defaults, + currentVersionProvider: { AppVersion(major: 3, minor: 0) }, + changelogDataProvider: { + [(version: "3.0", title: "v3.0", content: "Release notes")] + } + ) + + manager.checkForNewChangelog() + + #expect(manager.pendingChangelog?.version == AppVersion(major: 3, minor: 0)) + } + + @Test + func dismissingChangelogPersistsCurrentVersion() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: "hasLaunchedBefore") + defaults.set("2.2", forKey: "lastSeenAppVersion") + + let manager = ChangelogManager( + userDefaults: defaults, + currentVersionProvider: { AppVersion(major: 3, minor: 0) }, + changelogDataProvider: { + [(version: "3.0", title: "v3.0", content: "Release notes")] + } + ) + + manager.checkForNewChangelog() + manager.dismissChangelog() + + #expect(manager.pendingChangelog == nil) + #expect(defaults.string(forKey: "lastSeenAppVersion") == "3.0") + } + + @Test + func firstLaunchSuppressesChangelogAndMarksVersionSeen() { + let defaults = makeIsolatedDefaults() + + let manager = ChangelogManager( + userDefaults: defaults, + currentVersionProvider: { AppVersion(major: 3, minor: 0) }, + changelogDataProvider: { + [(version: "3.0", title: "v3.0", content: "Release notes")] + } + ) + + manager.checkForNewChangelog() + + #expect(manager.pendingChangelog == nil) + #expect(defaults.string(forKey: "lastSeenAppVersion") == "3.0") + } + + @Test + func missingCurrentVersionChangelogStillAdvancesLastSeenVersion() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: "hasLaunchedBefore") + defaults.set("2.2", forKey: "lastSeenAppVersion") + + let manager = ChangelogManager( + userDefaults: defaults, + currentVersionProvider: { AppVersion(major: 4, minor: 0) }, + changelogDataProvider: { + [(version: "3.0", title: "v3.0", content: "Release notes")] + } + ) + + manager.checkForNewChangelog() + + #expect(manager.pendingChangelog == nil) + #expect(defaults.string(forKey: "lastSeenAppVersion") == "4.0") + } +} diff --git a/ZettelTests/TagStoreTests.swift b/ZettelTests/TagStoreTests.swift new file mode 100644 index 0000000..4143040 --- /dev/null +++ b/ZettelTests/TagStoreTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import Zettel + +@MainActor +struct TagStoreTests { + @Test + func updateBuildsUsageCountsAndSortedTags() async { + let store = TagStore() + let notes = [ + Note(title: "One #Swift", content: "Body #Testing"), + Note(title: "Two #swift", content: "Body #apple"), + Note(title: "Three", content: "#swift") + ] + + store.updateTagsImmediately(from: notes) + await store.waitForPendingUpdates() + + #expect(store.getUsageCount(for: "swift") == 3) + #expect(store.getUsageCount(for: "testing") == 1) + #expect(store.sortedTags.map(\.id) == ["swift", "apple", "testing"]) + } + + @Test + func autocompleteCanExcludeCurrentlyEditedTag() async { + let store = TagStore() + let notes = [ + Note(title: "One", content: "#swift #swiftui #server") + ] + + store.updateTagsImmediately(from: notes) + await store.waitForPendingUpdates() + + let text = "working on #swift" + let range = NSRange(location: 11, length: 6) + let matches = store.getMatchingTags(for: "sw", excludingCurrentTag: range, fromText: text) + + #expect(matches.map(\.id) == ["swiftui"]) + } + + @Test + func filteringHelpersSupportAnyAndAllTagQueries() async { + let store = TagStore() + let note1 = Note(title: "One #swift", content: "#testing") + let note2 = Note(title: "Two #swift", content: "#apple") + let note3 = Note(title: "Three", content: "#apple") + let notes = [note1, note2, note3] + + store.updateTagsImmediately(from: notes) + await store.waitForPendingUpdates() + + #expect(store.getNotesWithTag("swift", from: notes).count == 2) + #expect(store.getNotesWithAnyTag(["testing", "apple"], from: notes).count == 3) + #expect(store.getNotesWithAllTags(["swift", "apple"], from: notes).map(\.title) == ["Two #swift"]) + } +} diff --git a/ZettelTests/TestSupport.swift b/ZettelTests/TestSupport.swift new file mode 100644 index 0000000..7fe2473 --- /dev/null +++ b/ZettelTests/TestSupport.swift @@ -0,0 +1,8 @@ +import Foundation + +func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "ZettelTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} diff --git a/configure.sh b/configure.sh index c598e6e..12a1ac6 100755 --- a/configure.sh +++ b/configure.sh @@ -48,7 +48,7 @@ fi # Update project file with environment variables sed -i '' "s/DEVELOPMENT_TEAM = \"\";/DEVELOPMENT_TEAM = $DEVELOPMENT_TEAM;/g" Zettel.xcodeproj/project.pbxproj -sed -i '' "s/PRODUCT_BUNDLE_IDENTIFIER = \"\";/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_IDENTIFIER;/g" Zettel.xcodeproj/project.pbxproj +sed -E -i '' "s/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/PRODUCT_BUNDLE_IDENTIFIER = $BUNDLE_IDENTIFIER;/g" Zettel.xcodeproj/project.pbxproj echo -e "${GREEN}✓ Configured project with:${NC}" echo " Development Team: $DEVELOPMENT_TEAM"