Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions wled/Service/ReleaseService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -52,10 +55,27 @@ class ReleaseService {
}

fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchRequest.propertiesToFetch = ["tagName", "publishedDate"]
Comment on lines 57 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The propertiesToFetch property is only effective when the resultType of the NSFetchRequest is set to .dictionaryResultType. Since this request uses the default .managedObjectResultType, this line is ignored by Core Data. For a small dataset like the one typically returned by the GitHub releases API, this optimization is unnecessary and should be removed to keep the code clean and avoid potential confusion.

Suggested change
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchRequest.propertiesToFetch = ["tagName", "publishedDate"]
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)


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
Expand Down
6 changes: 3 additions & 3 deletions wledTests/DeviceWebsocketListViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
187 changes: 187 additions & 0 deletions wledTests/ReleaseServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading