diff --git a/wled/Service/ReleaseService.swift b/wled/Service/ReleaseService.swift index bb5b094..d53cba4 100644 --- a/wled/Service/ReleaseService.swift +++ b/wled/Service/ReleaseService.swift @@ -34,14 +34,17 @@ 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 : "" } 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. @@ -52,10 +55,27 @@ class ReleaseService { } fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.propertiesToFetch = ["tagName", "publishedDate"] 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/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 new file mode 100644 index 0000000..ef1fb96 --- /dev/null +++ b/wledTests/ReleaseServiceTests.swift @@ -0,0 +1,187 @@ +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 { + + // 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 { + // 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) + } + + /// 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. + @discardableResult + private func insertVersion( + 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 + } + + // MARK: - getLatestVersion tests + + @Test func latestVersionUsesSemVerNotPublishedDate() throws { + // 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) + #expect(latest?.tagName == "0.16.0") + } + + @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) + #expect(latest?.tagName == "0.15.0") + } + + @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) + #expect(latest?.tagName == "0.16.0-b1") + } + + @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) + #expect(latest?.tagName == "0.15.0") + } + + @Test func latestVersionWithMultipleVersions() throws { + // Insert versions with intentionally misleading published dates + 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 latest = service.getLatestVersion(branch: .beta) + #expect(latest?.tagName == "0.16.0") + } + + @Test func latestVersionReturnsNilWhenEmpty() throws { + let latest = service.getLatestVersion(branch: .beta) + #expect(latest == nil) + } + + // MARK: - getNewerReleaseTag tests + + @Test func newerReleaseTagReturnsLatestWhenNewer() throws { + insertVersion(tagName: "0.16.0") + try context.save() + + let result = service.getNewerReleaseTag(versionName: "0.15.0", branch: .stable, ignoreVersion: "") + #expect(result == "0.16.0") + } + + @Test func newerReleaseTagReturnsEmptyWhenAlreadyLatest() throws { + insertVersion(tagName: "0.15.0") + try context.save() + + let result = service.getNewerReleaseTag(versionName: "0.15.0", branch: .stable, ignoreVersion: "") + #expect(result == "") + } + + @Test func newerReleaseTagRespectsIgnoreVersion() throws { + insertVersion(tagName: "0.16.0") + try context.save() + + 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") + } +}