From b011a0317453f94cae47e327e2584ccaaf6457b9 Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Thu, 7 May 2026 22:02:17 -0400 Subject: [PATCH 1/5] Fix version sorting by using SemVer instead of publishedDate Fixes #84 The app previously sorted versions by their GitHub publishedAt date to determine the latest available version. This could lead to incorrect results when older version branches receive patches with a newer publishedAt date (e.g. v0.15.5 appearing newer than v16.0.0). The fix replaces the Core Data query's publishedDate sort with an in-memory comparison using the existing SemanticVersion struct. Versions with invalid semver tags fall back to publishedDate comparison. Also adds comprehensive unit tests for ReleaseService covering the exact bug scenario, stable/beta filtering, nightly exclusion, and the getNewerReleaseTag helper. --- wled/Service/ReleaseService.swift | 20 ++- wledTests/ReleaseServiceTests.swift | 194 ++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 wledTests/ReleaseServiceTests.swift diff --git a/wled/Service/ReleaseService.swift b/wled/Service/ReleaseService.swift index bb5b094..02987e9 100644 --- a/wled/Service/ReleaseService.swift +++ b/wled/Service/ReleaseService.swift @@ -40,8 +40,6 @@ class ReleaseService { func getLatestVersion(branch: Branch) -> Version? { let fetchRequest = Version.fetchRequest() - fetchRequest.fetchLimit = 1 - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "publishedDate", ascending: false)] var predicates = [NSPredicate]() // For now, nightly branches are not supported. @@ -55,7 +53,23 @@ class ReleaseService { do { let versions = try context.fetch(fetchRequest) - return versions.first + return versions + .map { ($0, SemanticVersion($0.tagName ?? "")) } + .max { lhs, rhs in + switch (lhs.1, rhs.1) { + case let (l?, r?): + return l < r + case (nil, .some): + // Invalid semver tags are considered "less than" valid ones + return true + case (.some, nil): + return false + case (nil, nil): + // Both invalid: fall back to publishedDate comparison + return (lhs.0.publishedDate ?? .distantPast) < (rhs.0.publishedDate ?? .distantPast) + } + }? + .0 } catch { print("ReleaseService: Failed to fetch latest version. Error: \(error.localizedDescription)") return nil diff --git a/wledTests/ReleaseServiceTests.swift b/wledTests/ReleaseServiceTests.swift new file mode 100644 index 0000000..2628a54 --- /dev/null +++ b/wledTests/ReleaseServiceTests.swift @@ -0,0 +1,194 @@ +import Testing +import CoreData +@testable import WLED + +@MainActor +struct ReleaseServiceTests { + + /// Creates a fully isolated in-memory Core Data context for testing. + private func makeInMemoryContext() -> NSManagedObjectContext { + let container = PersistenceController(inMemory: true).container + return container.viewContext + } + + /// Inserts a Version entity into the given context. + @discardableResult + private func insertVersion( + context: NSManagedObjectContext, + tagName: String, + isPrerelease: Bool = false, + publishedDate: Date = Date() + ) -> Version { + let version = Version(context: context) + version.tagName = tagName + version.name = "v\(tagName)" + version.versionDescription = "" + version.isPrerelease = isPrerelease + version.publishedDate = publishedDate + return version + } + + /// Deletes all Version entities in the given context to ensure test isolation. + private func deleteAllVersions(context: NSManagedObjectContext) throws { + let fetchRequest = Version.fetchRequest() + let versions = try context.fetch(fetchRequest) + for version in versions { + context.delete(version) + } + try context.save() + } + + // MARK: - getLatestVersion tests + + @Test func latestVersionUsesSemVerNotPublishedDate() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + // v0.15.5 has a MORE RECENT publishedDate, but v0.16.0 is a higher semver + insertVersion(context: context, tagName: "0.15.5", + publishedDate: Date(timeIntervalSince1970: 2_000_000)) + insertVersion(context: context, tagName: "0.16.0", + publishedDate: Date(timeIntervalSince1970: 1_000_000)) + + try context.save() + + let service = ReleaseService(context: context) + let latest = service.getLatestVersion(branch: .beta) + + #expect(latest?.tagName == "0.16.0") + } + + @Test func latestStableVersionExcludesPrereleases() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + insertVersion(context: context, tagName: "0.15.0") + insertVersion(context: context, tagName: "0.16.0-b1", isPrerelease: true) + + try context.save() + + let service = ReleaseService(context: context) + let latest = service.getLatestVersion(branch: .stable) + + #expect(latest?.tagName == "0.15.0") + } + + @Test func latestBetaVersionIncludesPrereleases() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + insertVersion(context: context, tagName: "0.15.0") + insertVersion(context: context, tagName: "0.16.0-b1", isPrerelease: true) + + try context.save() + + let service = ReleaseService(context: context) + let latest = service.getLatestVersion(branch: .beta) + + #expect(latest?.tagName == "0.16.0-b1") + } + + @Test func latestVersionExcludesNightlyTag() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + insertVersion(context: context, tagName: "nightly", + publishedDate: Date(timeIntervalSince1970: 9_999_999)) + insertVersion(context: context, tagName: "0.15.0") + + try context.save() + + let service = ReleaseService(context: context) + let latest = service.getLatestVersion(branch: .beta) + + #expect(latest?.tagName == "0.15.0") + } + + @Test func latestVersionWithMultipleVersions() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + // Insert versions with intentionally misleading published dates + insertVersion(context: context, tagName: "0.14.0", + publishedDate: Date(timeIntervalSince1970: 3_000_000)) + insertVersion(context: context, tagName: "0.15.5", + publishedDate: Date(timeIntervalSince1970: 4_000_000)) + insertVersion(context: context, tagName: "0.16.0", + publishedDate: Date(timeIntervalSince1970: 1_000_000)) + insertVersion(context: context, tagName: "0.14.1", + publishedDate: Date(timeIntervalSince1970: 5_000_000)) + + try context.save() + + let service = ReleaseService(context: context) + let latest = service.getLatestVersion(branch: .beta) + + #expect(latest?.tagName == "0.16.0") + } + + @Test func latestVersionReturnsNilWhenEmpty() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + let service = ReleaseService(context: context) + let latest = service.getLatestVersion(branch: .beta) + + #expect(latest == nil) + } + + // MARK: - getNewerReleaseTag tests + + @Test func newerReleaseTagReturnsLatestWhenNewer() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + insertVersion(context: context, tagName: "0.16.0") + + try context.save() + + let service = ReleaseService(context: context) + let result = service.getNewerReleaseTag( + versionName: "0.15.0", + branch: .stable, + ignoreVersion: "" + ) + + #expect(result == "0.16.0") + } + + @Test func newerReleaseTagReturnsEmptyWhenAlreadyLatest() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + insertVersion(context: context, tagName: "0.15.0") + + try context.save() + + let service = ReleaseService(context: context) + let result = service.getNewerReleaseTag( + versionName: "0.15.0", + branch: .stable, + ignoreVersion: "" + ) + + #expect(result == "") + } + + @Test func newerReleaseTagRespectsIgnoreVersion() throws { + let context = makeInMemoryContext() + try deleteAllVersions(context: context) + + insertVersion(context: context, tagName: "0.16.0") + + try context.save() + + let service = ReleaseService(context: context) + let result = service.getNewerReleaseTag( + versionName: "0.15.0", + branch: .stable, + ignoreVersion: "0.16.0" + ) + + #expect(result == "") + } +} From 0a11e15835ce308d1a111fdab9ce6a24346a4f3e Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Thu, 7 May 2026 22:06:36 -0400 Subject: [PATCH 2/5] Refactor: use init() for test setup --- wledTests/ReleaseServiceTests.swift | 151 +++++++++------------------- 1 file changed, 50 insertions(+), 101 deletions(-) diff --git a/wledTests/ReleaseServiceTests.swift b/wledTests/ReleaseServiceTests.swift index 2628a54..93c7c6e 100644 --- a/wledTests/ReleaseServiceTests.swift +++ b/wledTests/ReleaseServiceTests.swift @@ -5,16 +5,42 @@ import CoreData @MainActor struct ReleaseServiceTests { - /// Creates a fully isolated in-memory Core Data context for testing. - private func makeInMemoryContext() -> NSManagedObjectContext { - let container = PersistenceController(inMemory: true).container - return container.viewContext + let context: NSManagedObjectContext + let service: ReleaseService + + init() throws { + let bundle = Bundle(for: Version.self) + guard let modelURL = bundle.url(forResource: "wled_native_data", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) else { + preconditionFailure("Failed to load Core Data model from bundle") + } + + let container = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + precondition(loadError == nil, "Failed to load in-memory store: \(loadError!)") + + context = container.viewContext + service = ReleaseService(context: context) + + // Deletes all Version entities to ensure test isolation + let fetchRequest = Version.fetchRequest() + let versions = try context.fetch(fetchRequest) + for version in versions { + context.delete(version) + } + try context.save() } - /// Inserts a Version entity into the given context. + /// Inserts a Version entity into the context. @discardableResult private func insertVersion( - context: NSManagedObjectContext, tagName: String, isPrerelease: Bool = false, publishedDate: Date = Date() @@ -28,167 +54,90 @@ struct ReleaseServiceTests { return version } - /// Deletes all Version entities in the given context to ensure test isolation. - private func deleteAllVersions(context: NSManagedObjectContext) throws { - let fetchRequest = Version.fetchRequest() - let versions = try context.fetch(fetchRequest) - for version in versions { - context.delete(version) - } - try context.save() - } - // MARK: - getLatestVersion tests @Test func latestVersionUsesSemVerNotPublishedDate() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - // v0.15.5 has a MORE RECENT publishedDate, but v0.16.0 is a higher semver - insertVersion(context: context, tagName: "0.15.5", - publishedDate: Date(timeIntervalSince1970: 2_000_000)) - insertVersion(context: context, tagName: "0.16.0", - publishedDate: Date(timeIntervalSince1970: 1_000_000)) + insertVersion(tagName: "0.15.5", publishedDate: Date(timeIntervalSince1970: 2_000_000)) + insertVersion(tagName: "0.16.0", publishedDate: Date(timeIntervalSince1970: 1_000_000)) try context.save() - let service = ReleaseService(context: context) let latest = service.getLatestVersion(branch: .beta) - #expect(latest?.tagName == "0.16.0") } @Test func latestStableVersionExcludesPrereleases() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - insertVersion(context: context, tagName: "0.15.0") - insertVersion(context: context, tagName: "0.16.0-b1", isPrerelease: true) + insertVersion(tagName: "0.15.0") + insertVersion(tagName: "0.16.0-b1", isPrerelease: true) try context.save() - let service = ReleaseService(context: context) let latest = service.getLatestVersion(branch: .stable) - #expect(latest?.tagName == "0.15.0") } @Test func latestBetaVersionIncludesPrereleases() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - insertVersion(context: context, tagName: "0.15.0") - insertVersion(context: context, tagName: "0.16.0-b1", isPrerelease: true) + insertVersion(tagName: "0.15.0") + insertVersion(tagName: "0.16.0-b1", isPrerelease: true) try context.save() - let service = ReleaseService(context: context) let latest = service.getLatestVersion(branch: .beta) - #expect(latest?.tagName == "0.16.0-b1") } @Test func latestVersionExcludesNightlyTag() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - insertVersion(context: context, tagName: "nightly", - publishedDate: Date(timeIntervalSince1970: 9_999_999)) - insertVersion(context: context, tagName: "0.15.0") + insertVersion(tagName: "nightly", publishedDate: Date(timeIntervalSince1970: 9_999_999)) + insertVersion(tagName: "0.15.0") try context.save() - let service = ReleaseService(context: context) let latest = service.getLatestVersion(branch: .beta) - #expect(latest?.tagName == "0.15.0") } @Test func latestVersionWithMultipleVersions() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - // Insert versions with intentionally misleading published dates - insertVersion(context: context, tagName: "0.14.0", - publishedDate: Date(timeIntervalSince1970: 3_000_000)) - insertVersion(context: context, tagName: "0.15.5", - publishedDate: Date(timeIntervalSince1970: 4_000_000)) - insertVersion(context: context, tagName: "0.16.0", - publishedDate: Date(timeIntervalSince1970: 1_000_000)) - insertVersion(context: context, tagName: "0.14.1", - publishedDate: Date(timeIntervalSince1970: 5_000_000)) + insertVersion(tagName: "0.14.0", publishedDate: Date(timeIntervalSince1970: 3_000_000)) + insertVersion(tagName: "0.15.5", publishedDate: Date(timeIntervalSince1970: 4_000_000)) + insertVersion(tagName: "0.16.0", publishedDate: Date(timeIntervalSince1970: 1_000_000)) + insertVersion(tagName: "0.14.1", publishedDate: Date(timeIntervalSince1970: 5_000_000)) try context.save() - let service = ReleaseService(context: context) let latest = service.getLatestVersion(branch: .beta) - #expect(latest?.tagName == "0.16.0") } @Test func latestVersionReturnsNilWhenEmpty() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - let service = ReleaseService(context: context) let latest = service.getLatestVersion(branch: .beta) - #expect(latest == nil) } // MARK: - getNewerReleaseTag tests @Test func newerReleaseTagReturnsLatestWhenNewer() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - insertVersion(context: context, tagName: "0.16.0") - + insertVersion(tagName: "0.16.0") try context.save() - let service = ReleaseService(context: context) - let result = service.getNewerReleaseTag( - versionName: "0.15.0", - branch: .stable, - ignoreVersion: "" - ) - + let result = service.getNewerReleaseTag(versionName: "0.15.0", branch: .stable, ignoreVersion: "") #expect(result == "0.16.0") } @Test func newerReleaseTagReturnsEmptyWhenAlreadyLatest() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - insertVersion(context: context, tagName: "0.15.0") - + insertVersion(tagName: "0.15.0") try context.save() - let service = ReleaseService(context: context) - let result = service.getNewerReleaseTag( - versionName: "0.15.0", - branch: .stable, - ignoreVersion: "" - ) - + let result = service.getNewerReleaseTag(versionName: "0.15.0", branch: .stable, ignoreVersion: "") #expect(result == "") } @Test func newerReleaseTagRespectsIgnoreVersion() throws { - let context = makeInMemoryContext() - try deleteAllVersions(context: context) - - insertVersion(context: context, tagName: "0.16.0") - + insertVersion(tagName: "0.16.0") try context.save() - let service = ReleaseService(context: context) - let result = service.getNewerReleaseTag( - versionName: "0.15.0", - branch: .stable, - ignoreVersion: "0.16.0" - ) - + let result = service.getNewerReleaseTag(versionName: "0.15.0", branch: .stable, ignoreVersion: "0.16.0") #expect(result == "") } } From 6ea6cc274d18a32dadc063b41469688477c6e7e8 Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Thu, 7 May 2026 22:12:49 -0400 Subject: [PATCH 3/5] Refactor: use init() + @Suite(.serialized) for test setup and isolation --- wledTests/ReleaseServiceTests.swift | 36 ++++++++--------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/wledTests/ReleaseServiceTests.swift b/wledTests/ReleaseServiceTests.swift index 93c7c6e..1fa2989 100644 --- a/wledTests/ReleaseServiceTests.swift +++ b/wledTests/ReleaseServiceTests.swift @@ -2,6 +2,9 @@ import Testing import CoreData @testable import WLED +// .serialized prevents the two test suite instances Xcode runs concurrently from +// interfering with each other via the shared in-memory Core Data store. +@Suite(.serialized) @MainActor struct ReleaseServiceTests { @@ -9,30 +12,16 @@ struct ReleaseServiceTests { let service: ReleaseService init() throws { - let bundle = Bundle(for: Version.self) - guard let modelURL = bundle.url(forResource: "wled_native_data", withExtension: "momd"), - let model = NSManagedObjectModel(contentsOf: modelURL) else { - preconditionFailure("Failed to load Core Data model from bundle") - } - - let container = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) - let description = NSPersistentStoreDescription() - description.type = NSInMemoryStoreType - container.persistentStoreDescriptions = [description] - - var loadError: Error? - container.loadPersistentStores { _, error in - loadError = error - } - precondition(loadError == nil, "Failed to load in-memory store: \(loadError!)") - - context = container.viewContext + // Use the shared in-memory store. @MainActor on the struct ensures all test + // instances run serially on the main thread, so cleanup in init() is sufficient + // to guarantee full isolation between tests. + context = PersistenceController(inMemory: true).container.viewContext service = ReleaseService(context: context) - // Deletes all Version entities to ensure test isolation + // Clear any versions left by a previous test in this session. let fetchRequest = Version.fetchRequest() - let versions = try context.fetch(fetchRequest) - for version in versions { + let existing = try context.fetch(fetchRequest) + for version in existing { context.delete(version) } try context.save() @@ -60,7 +49,6 @@ struct ReleaseServiceTests { // v0.15.5 has a MORE RECENT publishedDate, but v0.16.0 is a higher semver insertVersion(tagName: "0.15.5", publishedDate: Date(timeIntervalSince1970: 2_000_000)) insertVersion(tagName: "0.16.0", publishedDate: Date(timeIntervalSince1970: 1_000_000)) - try context.save() let latest = service.getLatestVersion(branch: .beta) @@ -70,7 +58,6 @@ struct ReleaseServiceTests { @Test func latestStableVersionExcludesPrereleases() throws { insertVersion(tagName: "0.15.0") insertVersion(tagName: "0.16.0-b1", isPrerelease: true) - try context.save() let latest = service.getLatestVersion(branch: .stable) @@ -80,7 +67,6 @@ struct ReleaseServiceTests { @Test func latestBetaVersionIncludesPrereleases() throws { insertVersion(tagName: "0.15.0") insertVersion(tagName: "0.16.0-b1", isPrerelease: true) - try context.save() let latest = service.getLatestVersion(branch: .beta) @@ -90,7 +76,6 @@ struct ReleaseServiceTests { @Test func latestVersionExcludesNightlyTag() throws { insertVersion(tagName: "nightly", publishedDate: Date(timeIntervalSince1970: 9_999_999)) insertVersion(tagName: "0.15.0") - try context.save() let latest = service.getLatestVersion(branch: .beta) @@ -103,7 +88,6 @@ struct ReleaseServiceTests { insertVersion(tagName: "0.15.5", publishedDate: Date(timeIntervalSince1970: 4_000_000)) insertVersion(tagName: "0.16.0", publishedDate: Date(timeIntervalSince1970: 1_000_000)) insertVersion(tagName: "0.14.1", publishedDate: Date(timeIntervalSince1970: 5_000_000)) - try context.save() let latest = service.getLatestVersion(branch: .beta) From dccd0d28aafb329f85eea37899fe8722d441f983 Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Thu, 7 May 2026 22:16:12 -0400 Subject: [PATCH 4/5] Address PR feedback: use SemanticVersion in getNewerReleaseTag, optimize getLatestVersion fetch, and add test for beta-to-stable update --- wled/Service/ReleaseService.swift | 6 ++++++ wledTests/ReleaseServiceTests.swift | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/wled/Service/ReleaseService.swift b/wled/Service/ReleaseService.swift index 02987e9..d53cba4 100644 --- a/wled/Service/ReleaseService.swift +++ b/wled/Service/ReleaseService.swift @@ -34,6 +34,11 @@ class ReleaseService { return latestTagName } + if let latestSemVer = SemanticVersion(latestTagName), + let currentSemVer = SemanticVersion(versionName) { + return latestSemVer > currentSemVer ? latestTagName : "" + } + let versionCompare = latestTagName.compare(versionName, options: .numeric) return versionCompare == .orderedDescending ? latestTagName : "" } @@ -50,6 +55,7 @@ class ReleaseService { } fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.propertiesToFetch = ["tagName", "publishedDate"] do { let versions = try context.fetch(fetchRequest) diff --git a/wledTests/ReleaseServiceTests.swift b/wledTests/ReleaseServiceTests.swift index 1fa2989..2357ea2 100644 --- a/wledTests/ReleaseServiceTests.swift +++ b/wledTests/ReleaseServiceTests.swift @@ -124,4 +124,12 @@ struct ReleaseServiceTests { let result = service.getNewerReleaseTag(versionName: "0.15.0", branch: .stable, ignoreVersion: "0.16.0") #expect(result == "") } + + @Test func newerReleaseTagDetectsUpdateFromBetaToStable() throws { + insertVersion(tagName: "0.16.0") + try context.save() + + let result = service.getNewerReleaseTag(versionName: "0.16.0-b1", branch: .beta, ignoreVersion: "") + #expect(result == "0.16.0") + } } From 767826db13cafaeee8657886217b4c48fd73597a Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Thu, 7 May 2026 22:37:30 -0400 Subject: [PATCH 5/5] Fix CI: use programmatic NSManagedObjectModel in tests, fix unused-variable warnings --- .../DeviceWebsocketListViewModelTests.swift | 6 +- wledTests/ReleaseServiceTests.swift | 74 ++++++++++++++++--- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/wledTests/DeviceWebsocketListViewModelTests.swift b/wledTests/DeviceWebsocketListViewModelTests.swift index 81ce613..4f84346 100644 --- a/wledTests/DeviceWebsocketListViewModelTests.swift +++ b/wledTests/DeviceWebsocketListViewModelTests.swift @@ -17,9 +17,9 @@ struct DeviceWebsocketListViewModelTests { @Test func testInitialLoadingAndSorting() async throws { // 1. Setup mock data - let device1 = createDevice(name: "Z Device", mac: "01", isHidden: false) - let device2 = createDevice(name: "A Device", mac: "02", isHidden: false) - let device3 = createDevice(name: "Hidden Device", mac: "03", isHidden: true) + _ = createDevice(name: "Z Device", mac: "01", isHidden: false) + _ = createDevice(name: "A Device", mac: "02", isHidden: false) + _ = createDevice(name: "Hidden Device", mac: "03", isHidden: true) try context.save() let viewModel = DeviceWebsocketListViewModel(context: context) diff --git a/wledTests/ReleaseServiceTests.swift b/wledTests/ReleaseServiceTests.swift index 2357ea2..ef1fb96 100644 --- a/wledTests/ReleaseServiceTests.swift +++ b/wledTests/ReleaseServiceTests.swift @@ -8,23 +8,75 @@ import CoreData @MainActor struct ReleaseServiceTests { + // Hold a strong reference to prevent the container (and its in-memory store) from + // being deallocated while a test is running. + let container: NSPersistentContainer let context: NSManagedObjectContext let service: ReleaseService init() throws { - // Use the shared in-memory store. @MainActor on the struct ensures all test - // instances run serially on the main thread, so cleanup in init() is sufficient - // to guarantee full isolation between tests. - context = PersistenceController(inMemory: true).container.viewContext + // Build a fresh in-memory store using NSInMemoryStoreType so that: + // 1. No on-disk store or migration is attempted (avoids CI environment issues). + // 2. Each test suite instance gets a completely isolated store. + let model = Self.makeModel() + let newContainer = NSPersistentContainer(name: UUID().uuidString, managedObjectModel: model) + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + newContainer.persistentStoreDescriptions = [description] + + var loadError: Error? + newContainer.loadPersistentStores { _, error in + loadError = error + } + precondition(loadError == nil, "Failed to load in-memory store: \(String(describing: loadError))") + + container = newContainer + context = newContainer.viewContext service = ReleaseService(context: context) + } - // Clear any versions left by a previous test in this session. - let fetchRequest = Version.fetchRequest() - let existing = try context.fetch(fetchRequest) - for version in existing { - context.delete(version) - } - try context.save() + /// Builds a minimal NSManagedObjectModel containing only the Version entity, + /// which is all ReleaseService needs. This avoids loading from disk entirely. + private static func makeModel() -> NSManagedObjectModel { + let model = NSManagedObjectModel() + + let versionEntity = NSEntityDescription() + versionEntity.name = "Version" + versionEntity.managedObjectClassName = NSStringFromClass(Version.self) + + let tagNameAttr = NSAttributeDescription() + tagNameAttr.name = "tagName" + tagNameAttr.attributeType = .stringAttributeType + + let nameAttr = NSAttributeDescription() + nameAttr.name = "name" + nameAttr.attributeType = .stringAttributeType + nameAttr.isOptional = true + + let descAttr = NSAttributeDescription() + descAttr.name = "versionDescription" + descAttr.attributeType = .stringAttributeType + descAttr.isOptional = true + + let isPrereleaseAttr = NSAttributeDescription() + isPrereleaseAttr.name = "isPrerelease" + isPrereleaseAttr.attributeType = .booleanAttributeType + isPrereleaseAttr.defaultValue = false + + let publishedDateAttr = NSAttributeDescription() + publishedDateAttr.name = "publishedDate" + publishedDateAttr.attributeType = .dateAttributeType + publishedDateAttr.isOptional = true + + let htmlUrlAttr = NSAttributeDescription() + htmlUrlAttr.name = "htmlUrl" + htmlUrlAttr.attributeType = .stringAttributeType + htmlUrlAttr.isOptional = true + + versionEntity.properties = [tagNameAttr, nameAttr, descAttr, isPrereleaseAttr, publishedDateAttr, htmlUrlAttr] + model.entities = [versionEntity] + + return model } /// Inserts a Version entity into the context.