Files
xcodesApp-mirror/XcodesTests/NewVersionNotificationTests.swift
Yurii ChukhlibandClaude 52320866c8 fix: notify on new Xcode version identity, not array-count growth
The "New Xcode version available" notification was triggered by an
array-count increase rather than by the appearance of a genuinely new
version identity (AvailableXcode.xcodeID). This caused two user-visible
bugs:

- False negative: when a new version is added and an old one removed in
  the same refresh (count unchanged, or even shrunk), no notification
  fired, so a genuinely new version landed silently.
- False positive: when the array grew without a new identity (a duplicate
  row, or a data-source switch returning an already-known xcodeID), a
  spurious "new version" banner appeared.

Extract the decision into a pure static helper
AppState.newlyAvailableXcodes(oldXcodes:newXcodes:) that computes the
xcodeID set difference, and notify iff it is non-empty. The empty-old
guard is preserved so the initial cache load (empty -> populated) does
not notify; the scheduleNotification arguments are unchanged.

Adds XcodesTests/NewVersionNotificationTests.swift with a red-before-green
regression suite (the false-negative and false-positive cases fail against
a buggy count-based mirror and pass against the identity-based body).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:43:44 +02:00

146 lines
6.4 KiB
Swift

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])
}
}