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
4 changes: 4 additions & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; };
CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; };
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; };
14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */; };
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; };
Expand Down Expand Up @@ -208,6 +209,7 @@
BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = "<group>"; };
CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = "<group>"; };
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = "<group>"; };
884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVersionNotificationTests.swift; sourceTree = "<group>"; };
CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = "<group>"; };
CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -594,6 +596,7 @@
CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */,
CAD2E7B72449575100113D76 /* AppStateTests.swift */,
CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */,
884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */,
);
path = XcodesTests;
Expand Down Expand Up @@ -970,6 +973,7 @@
CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */,
CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */,
CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */,
14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */,
CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
13 changes: 12 additions & 1 deletion Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class AppState: ObservableObject {
@Published var authenticationState: AuthenticationState = .unauthenticated
@Published var availableXcodes: [AvailableXcode] = [] {
willSet {
if newValue.count > availableXcodes.count && availableXcodes.count != 0 {
if !Self.newlyAvailableXcodes(oldXcodes: availableXcodes, newXcodes: newValue).isEmpty {
Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal)
}
updateAllXcodes(
Expand All @@ -56,6 +56,17 @@ class AppState: ObservableObject {
autoInstallIfNeeded()
}
}

/// Returns the `AvailableXcode`s in `newXcodes` whose `xcodeID` was not present in `oldXcodes`.
///
/// Empty when `oldXcodes` is empty, so the initial load (empty -> populated) is NOT treated
/// as "a new version since you last looked".
static func newlyAvailableXcodes(oldXcodes: [AvailableXcode], newXcodes: [AvailableXcode]) -> [AvailableXcode] {
guard !oldXcodes.isEmpty else { return [] }
let oldIDs = Set(oldXcodes.map(\.xcodeID))
return newXcodes.filter { !oldIDs.contains($0.xcodeID) }
}

@Published var allXcodes: [Xcode] = []
@Published var selectedXcodePath: String? {
willSet {
Expand Down
145 changes: 145 additions & 0 deletions XcodesTests/NewVersionNotificationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Version
import XcodesKit
@testable import Xcodes
import XCTest

/// Regression tests for the "New Xcode version available" notification trigger.
///
/// The decision to notify is extracted into the pure helper
/// `AppState.newlyAvailableXcodes(oldXcodes:newXcodes:)` and asserted directly here.
/// No notification spying and no `Current = .mock` are required: the helper is the single
/// source of truth that the `availableXcodes.willSet` predicate consults.
@MainActor
final class NewVersionNotificationTests: XCTestCase {
// MARK: - Fixtures

/// Builds an `AvailableXcode` for `version` with a distinct download URL, mirroring the
/// construction style in `AppStateUpdateTests.swift` (`Version("0.0.0")!`, three components).
/// `architectures` defaults to `nil`.
private func makeAvailableXcode(
version: String,
architectures: [Architecture]? = nil,
urlSuffix: String = ""
) -> AvailableXcode {
AvailableXcode(
version: Version(version)!,
url: URL(string: "https://example.com/Xcode-\(version)-\(urlSuffix).xip")!,
filename: "Xcode-\(version)-\(urlSuffix).xip",
releaseDate: nil,
architectures: architectures
)
}

/// The set of stable identities (`xcodeID`) for the given available Xcodes — an
/// order-independent comparison keyed on version + architecture, not array position.
private func identities(_ xcodes: [AvailableXcode]) -> Set<XcodeID> {
Set(xcodes.map(\.xcodeID))
}

// MARK: - Initial-load suppression

func testInitialLoadDoesNotNotify() {
// First population (empty -> populated) is the initial cache load, NOT "a new version
// since you last looked", so the result must be empty even though the array grew.
let old: [AvailableXcode] = []
let new = [makeAvailableXcode(version: "15.0.0")]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertTrue(result.isEmpty, "Initial population must not be treated as a new version")
}

// MARK: - True positive — one genuinely new version

func testGenuinelyNewVersionIsReported() {
let existingA = makeAvailableXcode(version: "15.0.0")
let existingB = makeAvailableXcode(version: "15.1.0")
let added = makeAvailableXcode(version: "16.0.0")
let old = [existingA, existingB]
let new = [existingA, existingB, added]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertEqual(identities(result), [added.xcodeID])
}

// MARK: - FALSE NEGATIVE (the bug) — new version added AND old version removed, count unchanged

func testNewVersionAddedAndOldRemovedIsReported() {
// The OLD count-based predicate saw "no growth" (2 -> 2) here and MISSED version C.
// A data source can drop an obsolete beta row the same refresh it adds the new one.
let existing = makeAvailableXcode(version: "15.1.0")
let removed = makeAvailableXcode(version: "15.0.0")
let added = makeAvailableXcode(version: "16.0.0")
let old = [removed, existing]
let new = [existing, added]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertEqual(identities(result), [added.xcodeID], "A genuinely new version must be reported even when the count is unchanged")
}

// MARK: - FALSE NEGATIVE variant — a new version appears while the list shrinks

func testNewVersionReportedEvenWhenListShrinks() {
// A genuinely new version can appear even when the overall count DECREASES: a data source
// prunes older rows the same refresh it surfaces the newest (3 -> 2 here). The OLD
// count-based predicate saw "no growth" and MISSED D; the identity-based helper reports it.
let droppedA = makeAvailableXcode(version: "15.0.0")
let droppedB = makeAvailableXcode(version: "15.1.0")
let kept = makeAvailableXcode(version: "16.0.0")
let added = makeAvailableXcode(version: "17.0.0")
let old = [droppedA, droppedB, kept]
let new = [kept, added]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertEqual(identities(result), [added.xcodeID], "A new version must be reported even when the list shrinks")
}

// MARK: - FALSE POSITIVE (the bug) — count grows with NO new identity

func testDuplicateIdentityIsNotNew() {
// The OLD count-based predicate FIRED here (1 -> 2); the fixed helper correctly does not.
// The feed returns a row whose xcodeID was already present (a duplicate, same version).
let present = makeAvailableXcode(version: "15.0.0")
let duplicate = makeAvailableXcode(version: "15.0.0", urlSuffix: "duplicate") // same version -> same xcodeID
let old = [present]
let new = [present, duplicate]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertTrue(result.isEmpty, "A duplicate of an existing xcodeID must not be treated as new")
}

// MARK: - Identical list

func testIdenticalListIsNotNew() {
let a = makeAvailableXcode(version: "15.0.0")
let b = makeAvailableXcode(version: "15.1.0")
let old = [a, b]
let new = [a, b]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertTrue(result.isEmpty)
}

// MARK: - Identity is version + architecture, not version alone

func testSameVersionDifferentArchitectureIsDistinctIdentity() {
// Two AvailableXcodes with the same version but different architectures have DIFFERENT
// xcodeIDs (XcodeID.id = version.description + architectures), so an Apple-Silicon-only
// release is distinct from a Universal release of the same version.
let universal: [Architecture] = [.arm64, .x86_64]
let appleSilicon: [Architecture] = [.arm64]
let universalRelease = makeAvailableXcode(version: "15.0.0", architectures: universal)
let appleSiliconRelease = makeAvailableXcode(version: "15.0.0", architectures: appleSilicon)
let old = [universalRelease]
let new = [universalRelease, appleSiliconRelease]

let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new)

XCTAssertEqual(identities(result), [appleSiliconRelease.xcodeID])
}
}