diff --git a/Package.swift b/Package.swift index a2b1ccf4b..49922ca56 100644 --- a/Package.swift +++ b/Package.swift @@ -140,7 +140,8 @@ let package = Package( "ProcessInfo/CMakeLists.txt", "FileManager/CMakeLists.txt", "URL/CMakeLists.txt", - "NotificationCenter/CMakeLists.txt" + "NotificationCenter/CMakeLists.txt", + "ProgressManager/CMakeLists.txt", ], cSettings: [ .define("_GNU_SOURCE", .when(platforms: [.linux])) @@ -185,7 +186,7 @@ let package = Package( "Locale/CMakeLists.txt", "Calendar/CMakeLists.txt", "CMakeLists.txt", - "Predicate/CMakeLists.txt" + "Predicate/CMakeLists.txt", ], cSettings: wasiLibcCSettings, swiftSettings: [ diff --git a/Sources/FoundationEssentials/CMakeLists.txt b/Sources/FoundationEssentials/CMakeLists.txt index a5a1e9c79..533238baa 100644 --- a/Sources/FoundationEssentials/CMakeLists.txt +++ b/Sources/FoundationEssentials/CMakeLists.txt @@ -45,6 +45,7 @@ add_subdirectory(Locale) add_subdirectory(NotificationCenter) add_subdirectory(Predicate) add_subdirectory(ProcessInfo) +add_subdirectory(ProgressManager) add_subdirectory(PropertyList) add_subdirectory(String) add_subdirectory(TimeZone) diff --git a/Sources/FoundationEssentials/ProgressManager/CMakeLists.txt b/Sources/FoundationEssentials/ProgressManager/CMakeLists.txt new file mode 100644 index 000000000..325b81e2b --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/CMakeLists.txt @@ -0,0 +1,23 @@ +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift open source project +## +## Copyright (c) 2025 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.md for the list of Swift project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## +target_sources(FoundationEssentials PRIVATE + ProgressFraction.swift + ProgressManager.swift + ProgressManager+Interop.swift + ProgressManager+Properties+Accessors.swift + ProgressManager+Properties+Definitions.swift + ProgressManager+Properties+Helpers.swift + ProgressManager+State.swift + ProgressReporter.swift + Subprogress.swift) diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressFraction.swift b/Sources/FoundationEssentials/ProgressManager/ProgressFraction.swift new file mode 100644 index 000000000..be1a836e3 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressFraction.swift @@ -0,0 +1,337 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +#if FOUNDATION_FRAMEWORK +internal import _ForSwiftFoundation +#endif + +internal struct ProgressFraction : Sendable, Equatable, CustomDebugStringConvertible { + var completed : Int + var total : Int? + private(set) var overflowed : Bool + + init() { + completed = 0 + total = nil + overflowed = false + } + + init(double: Double, overflow: Bool = false) { + if double == 0 { + self.completed = 0 + self.total = 1 + } else if double == 1 { + self.completed = 1 + self.total = 1 + } else { + (self.completed, self.total) = ProgressFraction._fromDouble(double) + } + self.overflowed = overflow + } + + init(completed: Int, total: Int?) { + self.total = total + self.completed = completed + self.overflowed = false + } + + // ---- + +#if FOUNDATION_FRAMEWORK + // Glue code for _NSProgressFraction and ProgressFraction + init(nsProgressFraction: _NSProgressFraction) { + self.init(completed: Int(nsProgressFraction.completed), total: Int(nsProgressFraction.total)) + } +#endif + + internal mutating func simplify() { + guard let total = self.total, total != 0 else { + return + } + + (self.completed, self.total) = ProgressFraction._simplify(completed, total) + } + + internal func simplified() -> ProgressFraction? { + if let total = self.total { + let simplified = ProgressFraction._simplify(completed, total) + return ProgressFraction(completed: simplified.0, total: simplified.1) + } else { + return nil + } + } + + static private func _math(lhs: ProgressFraction, rhs: ProgressFraction, whichOperator: (_ lhs : Double, _ rhs : Double) -> Double, whichOverflow : (_ lhs: Int, _ rhs: Int) -> (Int, overflow: Bool)) -> ProgressFraction { + // Mathematically, it is nonsense to add or subtract something with a denominator of 0. However, for the purposes of implementing Progress' fractions, we just assume that a zero-denominator fraction is "weightless" and return the other value. We still need to check for the case where they are both nonsense though. + precondition(!(lhs.total == 0 && rhs.total == 0), "Attempt to add or subtract invalid fraction") + guard let lhsTotal = lhs.total, lhsTotal != 0 else { + return rhs + } + guard let rhsTotal = rhs.total, rhsTotal != 0 else { + return lhs + } + + guard !lhs.overflowed && !rhs.overflowed else { + // If either has overflowed already, we preserve that + return ProgressFraction(double: whichOperator(lhs.fractionCompleted, rhs.fractionCompleted), overflow: true) + } + + //TODO: rdar://148758226 Overflow check + if let lcm = _leastCommonMultiple(lhsTotal, rhsTotal) { + let result = whichOverflow(lhs.completed * (lcm / lhsTotal), rhs.completed * (lcm / rhsTotal)) + if result.overflow { + return ProgressFraction(double: whichOperator(lhs.fractionCompleted, rhs.fractionCompleted), overflow: true) + } else { + return ProgressFraction(completed: result.0, total: lcm) + } + } else { + // Overflow - simplify and then try again + let lhsSimplified = lhs.simplified() + let rhsSimplified = rhs.simplified() + + guard let lhsSimplified = lhsSimplified, + let rhsSimplified = rhsSimplified, + let lhsSimplifiedTotal = lhsSimplified.total, + let rhsSimplifiedTotal = rhsSimplified.total else { + // Simplification failed, fall back to double math + return ProgressFraction(double: whichOperator(lhs.fractionCompleted, rhs.fractionCompleted), overflow: true) + } + + if let lcm = _leastCommonMultiple(lhsSimplifiedTotal, rhsSimplifiedTotal) { + let result = whichOverflow(lhsSimplified.completed * (lcm / lhsSimplifiedTotal), rhsSimplified.completed * (lcm / rhsSimplifiedTotal)) + if result.overflow { + // Use original lhs/rhs here + return ProgressFraction(double: whichOperator(lhs.fractionCompleted, rhs.fractionCompleted), overflow: true) + } else { + return ProgressFraction(completed: result.0, total: lcm) + } + } else { + // Still overflow + return ProgressFraction(double: whichOperator(lhs.fractionCompleted, rhs.fractionCompleted), overflow: true) + } + } + } + + static internal func +(lhs: ProgressFraction, rhs: ProgressFraction) -> ProgressFraction { + return _math(lhs: lhs, rhs: rhs, whichOperator: +, whichOverflow: { $0.addingReportingOverflow($1) }) + } + + static internal func -(lhs: ProgressFraction, rhs: ProgressFraction) -> ProgressFraction { + return _math(lhs: lhs, rhs: rhs, whichOperator: -, whichOverflow: { $0.subtractingReportingOverflow($1) }) + } + + static internal func *(lhs: ProgressFraction, rhs: ProgressFraction) -> ProgressFraction? { + guard !lhs.overflowed && !rhs.overflowed else { + // If either has overflowed already, we preserve that + return ProgressFraction(double: lhs.fractionCompleted * rhs.fractionCompleted, overflow: true) + } + + guard let lhsTotal = lhs.total, let rhsTotal = rhs.total else { + return nil + } + + let newCompleted = lhs.completed.multipliedReportingOverflow(by: rhs.completed) + let newTotal = lhsTotal.multipliedReportingOverflow(by: rhsTotal) + + if newCompleted.overflow || newTotal.overflow { + // Try simplifying, then do it again + let lhsSimplified = lhs.simplified() + let rhsSimplified = rhs.simplified() + + guard let lhsSimplified = lhsSimplified, + let rhsSimplified = rhsSimplified, + let lhsSimplifiedTotal = lhsSimplified.total, + let rhsSimplifiedTotal = rhsSimplified.total else { + return nil + } + + let newCompletedSimplified = lhsSimplified.completed.multipliedReportingOverflow(by: rhsSimplified.completed) + let newTotalSimplified = lhsSimplifiedTotal.multipliedReportingOverflow(by: rhsSimplifiedTotal) + + if newCompletedSimplified.overflow || newTotalSimplified.overflow { + // Still overflow + return ProgressFraction(double: lhs.fractionCompleted * rhs.fractionCompleted, overflow: true) + } else { + return ProgressFraction(completed: newCompletedSimplified.0, total: newTotalSimplified.0) + } + } else { + return ProgressFraction(completed: newCompleted.0, total: newTotal.0) + } + } + + static internal func /(lhs: ProgressFraction, rhs: Int) -> ProgressFraction? { + guard !lhs.overflowed else { + // If lhs has overflowed, we preserve that + return ProgressFraction(double: lhs.fractionCompleted / Double(rhs), overflow: true) + } + + guard let lhsTotal = lhs.total else { + return nil + } + + let newTotal = lhsTotal.multipliedReportingOverflow(by: rhs) + + if newTotal.overflow { + let simplified = lhs.simplified() + + guard let simplified = simplified, + let simplifiedTotal = simplified.total else { + return nil + } + + let newTotalSimplified = simplifiedTotal.multipliedReportingOverflow(by: rhs) + + if newTotalSimplified.overflow { + // Still overflow + return ProgressFraction(double: lhs.fractionCompleted / Double(rhs), overflow: true) + } else { + return ProgressFraction(completed: lhs.completed, total: newTotalSimplified.0) + } + } else { + return ProgressFraction(completed: lhs.completed, total: newTotal.0) + } + } + + static internal func ==(lhs: ProgressFraction, rhs: ProgressFraction) -> Bool { + if lhs.isNaN || rhs.isNaN { + // NaN fractions are never equal + return false + } else if lhs.total == rhs.total { + // Direct comparison of numerator + return lhs.completed == rhs.completed + } else if lhs.total == nil && rhs.total != nil { + return false + } else if lhs.total != nil && rhs.total == nil { + return false + } else if lhs.completed == 0 && rhs.completed == 0 { + return true + } else if lhs.completed == lhs.total && rhs.completed == rhs.total { + // Both finished (1) + return true + } else if (lhs.completed == 0 && rhs.completed != 0) || (lhs.completed != 0 && rhs.completed == 0) { + // One 0, one not 0 + return false + } else { + // Cross-multiply + guard let lhsTotal = lhs.total, let rhsTotal = rhs.total else { + return false + } + + let left = lhs.completed.multipliedReportingOverflow(by: rhsTotal) + let right = lhsTotal.multipliedReportingOverflow(by: rhs.completed) + + if !left.overflow && !right.overflow { + if left.0 == right.0 { + return true + } + } else { + // Try simplifying then cross multiply again + let lhsSimplified = lhs.simplified() + let rhsSimplified = rhs.simplified() + + guard let lhsSimplified = lhsSimplified, + let rhsSimplified = rhsSimplified, + let lhsSimplifiedTotal = lhsSimplified.total, + let rhsSimplifiedTotal = rhsSimplified.total else { + // Simplification failed, fall back to doubles + return lhs.fractionCompleted == rhs.fractionCompleted + } + + let leftSimplified = lhsSimplified.completed.multipliedReportingOverflow(by: rhsSimplifiedTotal) + let rightSimplified = lhsSimplifiedTotal.multipliedReportingOverflow(by: rhsSimplified.completed) + + if !leftSimplified.overflow && !rightSimplified.overflow { + if leftSimplified.0 == rightSimplified.0 { + return true + } + } else { + // Ok... fallback to doubles. This doesn't use an epsilon + return lhs.fractionCompleted == rhs.fractionCompleted + } + } + } + + return false + } + + // ---- + + internal var isFinished: Bool { + guard let total else { + return false + } + return completed >= total && completed > 0 && total > 0 + } + + internal var isIndeterminate: Bool { + return total == nil + } + + + internal var fractionCompleted : Double { + guard let total else { + return 0.0 + } + return Double(completed) / Double(total) + } + + + internal var isNaN : Bool { + return total == 0 + } + + internal var debugDescription : String { + return "\(completed) / \(total) (\(fractionCompleted)), overflowed: \(overflowed)" + } + + // ---- + + private static func _fromDouble(_ d : Double) -> (Int, Int) { + // This simplistic algorithm could someday be replaced with something better. + // Basically - how many 1/Nths is this double? + var denominator: Int + switch Int.bitWidth { + case 32: denominator = 1048576 // 2^20 - safe for 32-bit + case 64: denominator = 1073741824 // 2^30 - high precision for 64-bit + default: denominator = 131072 // 2^17 - ultra-safe fallback + } + let numerator = Int(d / (1.0 / Double(denominator))) + return (numerator, denominator) + } + + private static func _greatestCommonDivisor(_ inA : Int, _ inB : Int) -> Int { + // This is Euclid's algorithm. There are faster ones, like Knuth, but this is the simplest one for now. + var a = inA + var b = inB + repeat { + let tmp = b + b = a % b + a = tmp + } while (b != 0) + return a + } + + private static func _leastCommonMultiple(_ a : Int, _ b : Int) -> Int? { + // This division always results in an integer value because gcd(a,b) is a divisor of a. + // lcm(a,b) == (|a|/gcd(a,b))*b == (|b|/gcd(a,b))*a + let result = (a / _greatestCommonDivisor(a, b)).multipliedReportingOverflow(by: b) + if result.overflow { + return nil + } else { + return result.0 + } + } + + private static func _simplify(_ n : Int, _ d : Int) -> (Int, Int) { + let gcd = _greatestCommonDivisor(n, d) + return (n / gcd, d / gcd) + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Interop.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Interop.swift new file mode 100644 index 000000000..b1faea98e --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Interop.swift @@ -0,0 +1,324 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if FOUNDATION_FRAMEWORK +internal import _ForSwiftFoundation +internal import Synchronization + +//MARK: Progress Parent - Subprogress / ProgressReporter Child Interop +@available(FoundationPreview 6.2, *) +extension Progress { + + /// Returns a Subprogress which can be passed to any method that reports progress + /// It can be then used to create a child `ProgressManager` reporting to this `Progress` + /// + /// Delegates a portion of totalUnitCount to a future child `ProgressManager` instance. + /// + /// - Parameter count: Number of units delegated to a child instance of `ProgressManager` + /// which may be instantiated by `Subprogress` later when `reporter(totalCount:)` is called. + /// - Returns: A `Subprogress` instance. + public func makeChild(withPendingUnitCount count: Int) -> Subprogress { + + // Make a ProgressManager + let manager = ProgressManager(totalCount: 1) + + // Create a NSProgress - ProgressManager bridge for mirroring + let subprogressBridge = SubprogressBridge( + parent: self, + portion: Int64(count), + manager: manager + ) + + // Instantiate a Subprogress with ProgressManager as parent + // Store bridge + let subprogress = Subprogress( + parent: manager, + portionOfParent: 1, + subprogressBridge: subprogressBridge + ) + + return subprogress + } + + /// Adds a ProgressReporter as a child to a Progress, which constitutes a portion of Progress's totalUnitCount. + /// + /// - Parameters: + /// - reporter: A `ProgressReporter` instance. + /// - count: Number of units delegated from `self`'s `totalCount`. + public func addChild(_ reporter: ProgressReporter, withPendingUnitCount count: Int) { + + precondition(self.isCycle(reporter: reporter) == false, "Creating a cycle is not allowed.") + + // Create a NSProgress - ProgressReporter bridge + let reporterBridge = ProgressReporterBridge( + parent: self, + portion: Int64(count), + reporterBridge: reporter + ) + + // Store bridge + reporter.manager.addBridge(reporterBridge: reporterBridge) + } + + // MARK: Cycle detection + private func isCycle(reporter: ProgressReporter, visited: Set = []) -> Bool { + if self._parent() == nil { + return false + } + + if !(self._parent() is NSProgressBridge) { + return self._parent().isCycle(reporter: reporter) + } + + let unwrappedParent = (self._parent() as? NSProgressBridge)?.manager + if let unwrappedParent = unwrappedParent { + if unwrappedParent === reporter.manager { + return true + } + let updatedVisited = visited.union([unwrappedParent]) + return unwrappedParent.isCycleInterop(reporter: reporter, visited: updatedVisited) + } + return false + } +} + +@available(FoundationPreview 6.2, *) +//MARK: ProgressManager Parent - Progress Child Interop +extension ProgressManager { + + /// Adds a Foundation's `Progress` instance as a child which constitutes a certain `count` of `self`'s `totalCount`. + /// - Parameters: + /// - count: Number of units delegated from `self`'s `totalCount`. + /// - progress: `Progress` which receives the delegated `count`. + public func subprogress(assigningCount count: Int, to progress: Foundation.Progress) { + precondition(progress._parent() == nil, "Cannot assign a progress to more than one parent.") + + // Create a ProgressManager - NSProgress bridge + let progressBridge = NSProgressBridge( + manager: self, + progress: progress, + portion: count + ) + + // Add bridge as a parent + progress._setParent(progressBridge, portion: Int64(count)) + + // Store bridge + self.addBridge(nsProgressBridge: progressBridge) + } +} + +internal final class SubprogressBridge: Sendable { + + internal let progressBridge: Progress + internal let manager: ProgressManager + + init(parent: Progress, portion: Int64, manager: ProgressManager) { + self.progressBridge = Progress(totalUnitCount: 1, parent: parent, pendingUnitCount: portion) + self.manager = manager + + manager.addObserver { [weak self] observerState in + guard let self else { + return + } + + switch observerState { + case .fractionUpdated(let totalCount, let completedCount): + // This needs to change totalUnitCount before completedUnitCount otherwise progressBridge will finish and mess up the math + self.progressBridge.totalUnitCount = Int64(totalCount) + self.progressBridge.completedUnitCount = Int64(completedCount) + } + } + } +} + +internal final class ProgressReporterBridge: Sendable { + + internal let progressBridge: Progress + internal let reporterBridge: ProgressReporter + + init(parent: Progress, portion: Int64, reporterBridge: ProgressReporter) { + self.progressBridge = Progress( + totalUnitCount: Int64(reporterBridge.manager.totalCount ?? 0), + parent: parent, + pendingUnitCount: portion + ) + self.progressBridge.completedUnitCount = Int64(reporterBridge.manager.completedCount) + self.reporterBridge = reporterBridge + + let manager = reporterBridge.manager + + manager.addObserver { [weak self] observerState in + guard let self else { + return + } + + switch observerState { + case .fractionUpdated(let totalCount, let completedCount): + self.progressBridge.totalUnitCount = Int64(totalCount) + self.progressBridge.completedUnitCount = Int64(completedCount) + } + } + } + +} + +internal final class NSProgressBridge: Progress, @unchecked Sendable { + + internal let manager: ProgressManager + internal let managerBridge: ProgressManager + internal let progress: Progress + + init(manager: ProgressManager, progress: Progress, portion: Int) { + self.manager = manager + self.managerBridge = ProgressManager(totalCount: Int(progress.totalUnitCount)) + self.progress = progress + super.init(parent: nil, userInfo: nil) + + managerBridge.withProperties { properties in + properties.completedCount = Int(progress.completedUnitCount) + } + + let position = manager.addChild( + child: managerBridge, + portion: portion, + childFraction: ProgressFraction(completed: Int(completedUnitCount), total: Int(totalUnitCount)) + ) + managerBridge.addParent(parent: manager, positionInParent: position) + } + + // Overrides the _updateChild func that Foundation.Progress calls to update parent + // so that the parent that gets updated is the ProgressManager parent + override func _updateChild(_ child: Foundation.Progress, fraction: _NSProgressFractionTuple, portion: Int64) { + managerBridge.withProperties { properties in + properties.totalCount = Int(fraction.next.total) + properties.completedCount = Int(fraction.next.completed) + } + + managerBridge.markSelfDirty() + } +} + +@available(FoundationPreview 6.2, *) +extension ProgressManager { + // Keeping this as an enum in case we have other states to track in the future. + internal enum ObserverState { + case fractionUpdated(totalCount: Int, completedCount: Int) + } + + internal struct InteropObservation { + let subprogressBridge: SubprogressBridge? + var reporterBridge: ProgressReporterBridge? + var nsProgressBridge: Foundation.Progress? + } + + internal enum InteropType { + case interopMirror(ProgressManager) + case interopObservation(InteropObservation) + + internal var totalCount: Int? { + switch self { + case .interopMirror(let mirror): + mirror.totalCount + case .interopObservation: + nil + } + } + + internal var completedCount: Int? { + switch self { + case .interopMirror(let mirror): + mirror.completedCount + case .interopObservation: + nil + } + } + + internal var fractionCompleted: Double? { + switch self { + case .interopMirror(let mirror): + mirror.fractionCompleted + case .interopObservation: + nil + } + } + + internal var isIndeterminate: Bool? { + switch self { + case .interopMirror(let mirror): + mirror.isIndeterminate + case .interopObservation: + nil + } + } + + internal var isFinished: Bool? { + switch self { + case .interopMirror(let mirror): + mirror.isFinished + case .interopObservation: + nil + } + } + } +} + +extension ProgressManager.State { + internal func notifyObservers(with observerState: ProgressManager.ObserverState) { + for observer in observers { + observer(observerState) + } + } +} + +@available(FoundationPreview 6.2, *) +extension ProgressManager { + //MARK: Interop Methods + /// Adds `observer` to list of `_observers` in `self`. + internal func addObserver(observer: @escaping @Sendable (ObserverState) -> Void) { + state.withLock { state in + state.observers.append(observer) + } + } + + /// Notifies all `_observers` of `self` when `state` changes. + internal func notifyObservers(with observedState: ObserverState) { + state.withLock { state in + for observer in state.observers { + observer(observedState) + } + } + } + + internal func addBridge(reporterBridge: ProgressReporterBridge? = nil, nsProgressBridge: Foundation.Progress? = nil) { + state.withLock { state in + var interopObservation = InteropObservation(subprogressBridge: nil) + + if let reporterBridge { + interopObservation.reporterBridge = reporterBridge + } + + if let nsProgressBridge { + interopObservation.nsProgressBridge = nsProgressBridge + } + + state.interopType = .interopObservation(interopObservation) + } + } + + internal func setInteropChild(interopMirror: ProgressManager) { + state.withLock { state in + state.interopType = .interopMirror(interopMirror) + } + } +} +#endif diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Accessors.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Accessors.swift new file mode 100644 index 000000000..6dc163477 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Accessors.swift @@ -0,0 +1,602 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +internal import Synchronization + +@available(FoundationPreview 6.2, *) +extension ProgressManager { + + // MARK: Methods to Read & Write Additional Properties of single ProgressManager node + + /// Mutates any settable properties that convey information about progress. + public func withProperties( + _ closure: (inout sending Values) throws(E) -> sending T + ) throws(E) -> sending T { + return try state.withLock { (state) throws(E) -> T in + var values = Values(state: state) + // This is done to avoid copy on write later +#if FOUNDATION_FRAMEWORK + state = State( + selfFraction: ProgressFraction(), + children: [], + parents: [], + totalFileCount: ProgressManager.Properties.TotalFileCount.defaultValue, + completedFileCount: ProgressManager.Properties.CompletedFileCount.defaultValue, + totalByteCount: ProgressManager.Properties.TotalByteCount.defaultValue, + completedByteCount: ProgressManager.Properties.CompletedByteCount.defaultValue, + throughput: ProgressManager.Properties.Throughput.defaultValue, + estimatedTimeRemaining: ProgressManager.Properties.EstimatedTimeRemaining.defaultValue, + propertiesInt: [:], + propertiesDouble: [:], + propertiesString: [:], + propertiesURL: [:], + propertiesUInt64: [:], + observers: [], + interopType: nil, + ) +#else + state = State( + selfFraction: ProgressFraction(), + children: [], + parents: [], + totalFileCount: ProgressManager.Properties.TotalFileCount.defaultValue, + completedFileCount: ProgressManager.Properties.CompletedFileCount.defaultValue, + totalByteCount: ProgressManager.Properties.TotalByteCount.defaultValue, + completedByteCount: ProgressManager.Properties.CompletedByteCount.defaultValue, + throughput: ProgressManager.Properties.Throughput.defaultValue, + estimatedTimeRemaining: ProgressManager.Properties.EstimatedTimeRemaining.defaultValue, + propertiesInt: [:], + propertiesDouble: [:], + propertiesString: [:], + propertiesURL: [:], + propertiesUInt64: [:] + ) +#endif + let result = try closure(&values) + if values.fractionalCountDirty { + markSelfDirty(parents: values.state.parents) + } + + if values.totalFileCountDirty { + markSelfDirty(property: Properties.TotalFileCount.self, parents: values.state.parents) + } + + if values.completedFileCountDirty { + markSelfDirty(property: Properties.CompletedFileCount.self, parents: values.state.parents) + } + + if values.totalByteCountDirty { + markSelfDirty(property: Properties.TotalByteCount.self, parents: values.state.parents) + } + + if values.completedByteCountDirty { + markSelfDirty(property: Properties.CompletedByteCount.self, parents: values.state.parents) + } + + if values.throughputDirty { + markSelfDirty(property: Properties.Throughput.self, parents: values.state.parents) + } + + if values.estimatedTimeRemainingDirty { + markSelfDirty(property: Properties.EstimatedTimeRemaining.self, parents: values.state.parents) + } + + if values.fileURLDirty { + markSelfDirty(property: Properties.FileURL.self, parents: values.state.parents) + } + + if values.dirtyPropertiesInt.count > 0 { + for property in values.dirtyPropertiesInt { + markSelfDirty(property: property, parents: values.state.parents) + } + } + + if values.dirtyPropertiesDouble.count > 0 { + for property in values.dirtyPropertiesDouble { + markSelfDirty(property: property, parents: values.state.parents) + } + } + + if values.dirtyPropertiesString.count > 0 { + for property in values.dirtyPropertiesString { + markSelfDirty(property: property, parents: values.state.parents) + } + } + + if values.dirtyPropertiesURL.count > 0 { + for property in values.dirtyPropertiesURL { + markSelfDirty(property: property, parents: values.state.parents) + } + } + + if values.dirtyPropertiesUInt64.count > 0 { + for property in values.dirtyPropertiesUInt64 { + markSelfDirty(property: property, parents: values.state.parents) + } + } +#if FOUNDATION_FRAMEWORK + if let observerState = values.observerState { + switch state.interopType { + case .interopObservation(let observation): + if let _ = observation.reporterBridge { + notifyObservers(with: observerState) + } + case .interopMirror: + break + default: + break + } + } +#endif + state = values.state + return result + } + } + + /// A container that holds values for properties that specify information on progress. + @dynamicMemberLookup + public struct Values : Sendable { + //TODO: rdar://149225947 Non-escapable conformance + internal var state: State + + internal var fractionalCountDirty = false + internal var totalFileCountDirty = false + internal var completedFileCountDirty = false + internal var totalByteCountDirty = false + internal var completedByteCountDirty = false + internal var throughputDirty = false + internal var estimatedTimeRemainingDirty = false + internal var fileURLDirty = false + internal var dirtyPropertiesInt: [MetatypeWrapper] = [] + internal var dirtyPropertiesDouble: [MetatypeWrapper] = [] + internal var dirtyPropertiesString: [MetatypeWrapper] = [] + internal var dirtyPropertiesURL: [MetatypeWrapper] = [] + internal var dirtyPropertiesUInt64: [MetatypeWrapper] = [] +#if FOUNDATION_FRAMEWORK + internal var observerState: ObserverState? +#endif + + /// The total units of work. + public var totalCount: Int? { + get { + state.getTotalCount() + } + + set { + guard newValue != state.selfFraction.total else { + return + } + + state.selfFraction.total = newValue + +#if FOUNDATION_FRAMEWORK + interopNotifications() +#endif + + fractionalCountDirty = true + } + } + + /// The completed units of work. + public var completedCount: Int { + mutating get { + state.getCompletedCount() + } + + set { + guard newValue != state.selfFraction.completed else { + return + } + + state.selfFraction.completed = newValue + +#if FOUNDATION_FRAMEWORK + interopNotifications() +#endif + fractionalCountDirty = true + } + } + + /// Gets or sets the total file count property. + /// - Parameter key: A key path to the `TotalFileCount` property type. + public subscript(dynamicMember key: KeyPath) -> Int { + get { + return state.totalFileCount + } + + set { + + guard newValue != state.totalFileCount else { + return + } + + state.totalFileCount = newValue + + totalFileCountDirty = true + } + } + + /// Gets or sets the completed file count property. + /// - Parameter key: A key path to the `CompletedFileCount` property type. + public subscript(dynamicMember key: KeyPath) -> Int { + get { + return state.completedFileCount + } + + set { + + guard newValue != state.completedFileCount else { + return + } + + state.completedFileCount = newValue + + completedFileCountDirty = true + } + } + + /// Gets or sets the total byte count property. + /// - Parameter key: A key path to the `TotalByteCount` property type. + public subscript(dynamicMember key: KeyPath) -> UInt64 { + get { + return state.totalByteCount + } + + set { + guard newValue != state.totalByteCount else { + return + } + + state.totalByteCount = newValue + + totalByteCountDirty = true + } + } + + /// Gets or sets the completed byte count property. + /// - Parameter key: A key path to the `CompletedByteCount` property type. + public subscript(dynamicMember key: KeyPath) -> UInt64 { + get { + return state.completedByteCount + } + + set { + guard newValue != state.completedByteCount else { + return + } + + state.completedByteCount = newValue + + completedByteCountDirty = true + } + } + + /// Gets or sets the throughput property. + /// - Parameter key: A key path to the `Throughput` property type. + public subscript(dynamicMember key: KeyPath) -> UInt64 { + get { + return state.throughput + } + + set { + guard newValue != state.throughput else { + return + } + + state.throughput = newValue + + throughputDirty = true + } + } + + /// Gets or sets the estimated time remaining property. + /// - Parameter key: A key path to the `EstimatedTimeRemaining` property type. + public subscript(dynamicMember key: KeyPath) -> Duration { + get { + return state.estimatedTimeRemaining + } + + set { + guard newValue != state.estimatedTimeRemaining else { + return + } + + state.estimatedTimeRemaining = newValue + + estimatedTimeRemainingDirty = true + } + } + + /// Gets or sets the file URL property. + /// - Parameter key: A key path to the `FileURL` property type. + public subscript(dynamicMember key: KeyPath) -> URL? { + get { + return state.fileURL + } + + set { + guard newValue != state.fileURL else { + return + } + + state.fileURL = newValue + + fileURLDirty = true + } + } + + /// Gets or sets custom integer properties. + /// + /// This subscript provides read-write access to custom progress properties where both the value + /// and summary types are `Int`. If the property has not been set, the getter returns the + /// property's default value. + /// + /// - Parameter key: A key path to the custom integer property type. + public subscript(dynamicMember key: KeyPath) -> Int where P.Value == Int, P.Summary == Int { + get { + return state.propertiesInt[MetatypeWrapper(P.self)] ?? P.defaultValue + } + + set { + guard newValue != state.propertiesInt[MetatypeWrapper(P.self)] else { + return + } + + state.propertiesInt[MetatypeWrapper(P.self)] = newValue + + dirtyPropertiesInt.append(MetatypeWrapper(P.self)) + } + } + + /// Gets or sets custom double properties. + /// + /// This subscript provides read-write access to custom progress properties where both the value + /// and summary types are `Double`. If the property has not been set, the getter returns the + /// property's default value. + /// + /// - Parameter key: A key path to the custom double property type. + public subscript(dynamicMember key: KeyPath) -> P.Value where P.Value == Double, P.Summary == Double { + get { + return state.propertiesDouble[MetatypeWrapper(P.self)] ?? P.defaultValue + } + + set { + guard newValue != state.propertiesDouble[MetatypeWrapper(P.self)] else { + return + } + + state.propertiesDouble[MetatypeWrapper(P.self)] = newValue + + dirtyPropertiesDouble.append(MetatypeWrapper(P.self)) + } + } + + /// Gets or sets custom string properties. + /// + /// This subscript provides read-write access to custom progress properties where the value + /// type is `String?` and the summary type is `[String?]`. If the property has not been set, + /// the getter returns the property's default value. + /// + /// - Parameter key: A key path to the custom string property type. + public subscript(dynamicMember key: KeyPath) -> String? where P.Value == String?, P.Summary == [String?] { + get { + return state.propertiesString[MetatypeWrapper(P.self)] ?? P.self.defaultValue + } + + set { + guard newValue != state.propertiesString[MetatypeWrapper(P.self)] else { + return + } + + state.propertiesString[MetatypeWrapper(P.self)] = newValue + + dirtyPropertiesString.append(MetatypeWrapper(P.self)) + } + } + + /// Gets or sets custom URL properties. + /// + /// This subscript provides read-write access to custom progress properties where the value + /// type is `URL?` and the summary type is `[URL?]`. If the property has not been set, + /// the getter returns the property's default value. + /// + /// - Parameter key: A key path to the custom URL property type. + public subscript(dynamicMember key: KeyPath) -> URL? where P.Value == URL?, P.Summary == [URL?] { + get { + return state.propertiesURL[MetatypeWrapper(P.self)] ?? P.self.defaultValue + } + + set { + guard newValue != state.propertiesURL[MetatypeWrapper(P.self)] else { + return + } + + state.propertiesURL[MetatypeWrapper(P.self)] = newValue + + dirtyPropertiesURL.append(MetatypeWrapper(P.self)) + } + } + + /// Gets or sets custom UInt64 properties. + /// + /// This subscript provides read-write access to custom progress properties where the value + /// type is `UInt64` and the summary type is `[UInt64]`. If the property has not been set, + /// the getter returns the property's default value. + /// + /// - Parameter key: A key path to the custom UInt64 property type. + public subscript(dynamicMember key: KeyPath) -> UInt64? where P.Value == UInt64, P.Summary == [UInt64] { + get { + return state.propertiesUInt64[MetatypeWrapper(P.self)] ?? P.self.defaultValue + } + + set { + guard newValue != state.propertiesUInt64[MetatypeWrapper(P.self)] else { + return + } + + state.propertiesUInt64[MetatypeWrapper(P.self)] = newValue + + dirtyPropertiesUInt64.append(MetatypeWrapper(P.self)) + } + } + +#if FOUNDATION_FRAMEWORK + private mutating func interopNotifications() { + switch state.interopType { + case .interopObservation(let observation): + observation.subprogressBridge?.manager.notifyObservers(with:.fractionUpdated(totalCount: state.selfFraction.total ?? 0, completedCount: state.selfFraction.completed)) + self.observerState = .fractionUpdated(totalCount: state.selfFraction.total ?? 0, completedCount: state.selfFraction.completed) + case .interopMirror: + break + default: + break + } + } +#endif + } + + internal func getProperties( + _ closure: (sending Values) throws(E) -> sending T + ) throws(E) -> sending T { + try state.withLock { state throws(E) -> T in + let values = Values(state: state) + let result = try closure(values) + return result + } + } + + // MARK: Methods to Read Additional Properties of Subtree with ProgressManager as root + + /// Returns a summary for a custom integer property across the progress subtree. + /// + /// This method aggregates the values of a custom integer property from this progress manager + /// and all its children, returning a consolidated summary value. + /// + /// - Parameter property: The type of the integer property to summarize. Must be a property + /// where both the value and summary types are `Int`. + /// - Returns: An `Int` summary value for the specified property. + public func summary(of property: P.Type) -> P.Summary where P.Value == Int, P.Summary == Int { + return getUpdatedIntSummary(property: MetatypeWrapper(property)) + } + + /// Returns a summary for a custom double property across the progress subtree. + /// + /// This method aggregates the values of a custom double property from this progress manager + /// and all its children, returning a consolidated summary value. + /// + /// - Parameter property: The type of the double property to summarize. Must be a property + /// where both the value and summary types are `Double`. + /// - Returns: A `Double` summary value for the specified property. + public func summary(of property: P.Type) -> P.Summary where P.Value == Double, P.Summary == Double { + return getUpdatedDoubleSummary(property: MetatypeWrapper(property)) + } + + /// Returns a summary for a custom string property across the progress subtree. + /// + /// This method aggregates the values of a custom string property from this progress manager + /// and all its children, returning a consolidated summary value. + /// + /// - Parameter property: The type of the string property to summarize. Must be a property + /// where both the value type is `String?` and the summary type is `[String?]`. + /// - Returns: A `[String?]` summary value for the specified property. + public func summary(of property: P.Type) -> P.Summary where P.Value == String?, P.Summary == [String?] { + return getUpdatedStringSummary(property: MetatypeWrapper(property)) + } + + /// Returns a summary for a custom URL property across the progress subtree. + /// + /// This method aggregates the values of a custom URL property from this progress manager + /// and all its children, returning a consolidated summary value as an array of URLs. + /// + /// - Parameter property: The type of the URL property to summarize. Must be a property + /// where the value type is `URL?` and the summary type is `[URL?]`. + /// - Returns: A `[URL?]` summary value for the specified property. + public func summary(of property: P.Type) -> P.Summary where P.Value == URL?, P.Summary == [URL?] { + return getUpdatedURLSummary(property: MetatypeWrapper(property)) + } + + /// Returns a summary for a custom UInt64 property across the progress subtree. + /// + /// This method aggregates the values of a custom UInt64 property from this progress manager + /// and all its children, returning a consolidated summary value as an array of UInt64 values. + /// + /// - Parameter property: The type of the UInt64 property to summarize. Must be a property + /// where the value type is `UInt64` and the summary type is `[UInt64]`. + /// - Returns: A `[UInt64]` summary value for the specified property. + public func summary(of property: P.Type) -> P.Summary where P.Value == UInt64, P.Summary == [UInt64] { + return getUpdatedUInt64Summary(property: MetatypeWrapper(property)) + } + + /// Returns the total file count across the progress subtree. + /// + /// - Parameter property: The `TotalFileCount` property type. + /// - Returns: The sum of all total file counts across the entire progress subtree. + public func summary(of property: ProgressManager.Properties.TotalFileCount.Type) -> Int { +// return getUpdatedFileCount(type: .total) + return totalFileCount + } + + /// Returns the completed file count across the progress subtree. + /// + /// - Parameter property: The `CompletedFileCount` property type. + /// - Returns: The sum of all completed file counts across the entire progress subtree. + public func summary(of property: ProgressManager.Properties.CompletedFileCount.Type) -> Int { +// return getUpdatedFileCount(type: .completed) + return completedFileCount + } + + /// Returns the total byte count across the progress subtree. + /// + /// - Parameter property: The `TotalByteCount` property type. + /// - Returns: The sum of all total byte counts across the entire progress subtree, in bytes. + public func summary(of property: ProgressManager.Properties.TotalByteCount.Type) -> UInt64 { + return getUpdatedByteCount(type: .total) + } + + /// Returns the completed byte count across the progress subtree. + /// + /// - Parameter property: The `CompletedByteCount` property type. + /// - Returns: The sum of all completed byte counts across the entire progress subtree, in bytes. + public func summary(of property: ProgressManager.Properties.CompletedByteCount.Type) -> UInt64 { + return getUpdatedByteCount(type: .completed) + } + + /// Returns the average throughput across the progress subtree. + /// + /// - Parameter property: The `Throughput` property type. + /// - Returns: The average throughput across the entire progress subtree, in bytes per second. + /// + /// - Note: The throughput is calculated as the sum of all throughput values divided by the count + /// of progress managers that have throughput data. + public func summary(of property: ProgressManager.Properties.Throughput.Type) -> [UInt64] { + return getUpdatedThroughput() + } + + /// Returns the maximum estimated time remaining for completion across the progress subtree. + /// + /// - Parameter property: The `EstimatedTimeRemaining` property type. + /// - Returns: The estimated duration until completion for the entire progress subtree. + /// + /// - Note: The estimation is based on current throughput and remaining work. The accuracy + /// depends on the consistency of the processing rate. + public func summary(of property: ProgressManager.Properties.EstimatedTimeRemaining.Type) -> Duration { + return getUpdatedEstimatedTimeRemaining() + } + + /// Returns all file URLs being processed across the progress subtree. + /// + /// - Parameter property: The `FileURL` property type. + /// - Returns: An array containing all file URLs across the entire progress subtree. + public func summary(of property: ProgressManager.Properties.FileURL.Type) -> [URL?] { + return getUpdatedFileURL() + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Definitions.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Definitions.swift new file mode 100644 index 000000000..fac192256 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Definitions.swift @@ -0,0 +1,301 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +@available(FoundationPreview 6.2, *) +extension ProgressManager { + + /// A type that conveys additional task-specific information on progress. + /// + /// The `Property` protocol defines custom properties that can be associated with progress tracking. + /// These properties allow you to store and aggregate additional information alongside the + /// standard progress metrics such as `totalCount` and `completedCount`. + public protocol Property: SendableMetatype { + + /// The type used for individual values of this property. + /// + /// This associated type represents the type of property values + /// that can be set on progress managers. Must be `Sendable` and `Equatable`. + /// The currently allowed types are `Int`, `Double`, `String?`, `URL?` or `UInt64`. + associatedtype Value: Sendable, Equatable + + /// The type used for aggregated summaries of this property. + /// + /// This associated type represents the type used when summarizing property values + /// across multiple progress managers in a subtree. + /// The currently allowed types are `Int`, `Double`, `[String?]`, `[URL?]` or `[UInt64]`. + associatedtype Summary: Sendable, Equatable + + /// A unique identifier for this property type. + /// + /// The key should use reverse DNS style notation to ensure uniqueness across different + /// frameworks and applications. + /// + /// - Returns: A unique string identifier for this property type. + static var key: String { get } + + /// The default value to return when property is not set to a specific value. + /// + /// This value is used when a progress manager doesn't have an explicit value set + /// for this property type. + /// + /// - Returns: The default value for this property type. + static var defaultValue: Value { get } + + /// The default summary value for this property type. + /// + /// This value is used as the initial summary when no property values have been + /// aggregated yet. + /// + /// - Returns: The default summary value for this property type. + static var defaultSummary: Summary { get } + + /// Reduces a property value into an accumulating summary. + /// + /// This method is called to incorporate individual property values into a summary + /// that represents the aggregated state across multiple progress managers. + /// + /// - Parameters: + /// - summary: The accumulating summary value to modify. + /// - value: The individual property value to incorporate into the summary. + static func reduce(into summary: inout Summary, value: Value) + + /// Merges two summary values into a single combined summary. + /// + /// This method is called to combine summary values from different branches + /// of the progress manager hierarchy into a unified summary. + /// + /// - Parameters: + /// - summary1: The first summary to merge. + /// - summary2: The second summary to merge. + /// - Returns: A new summary that represents the combination of both input summaries. + static func merge(_ summary1: Summary, _ summary2: Summary) -> Summary + + /// Determines how to handle summary data when a progress manager is deinitialized. + /// + /// This method is used when a progress manager in the hierarchy is being + /// deinitialized and its accumulated summary needs to be processed in relation to + /// its parent's summary. The behavior can vary depending on the property type: + /// + /// - For additive properties (like file counts, byte counts): The self summary + /// is typically added to the parent summary to preserve the accumulated progress. + /// - For max-based properties (like estimated time remaining): The parent summary + /// is typically preserved as it represents an existing estimate. + /// - For collection-based properties (like file URLs): The self summary may be + /// discarded to avoid accumulating stale references. + /// + /// - Parameters: + /// - parentSummary: The current summary value of the parent progress manager. + /// - selfSummary: The final summary value from the progress manager being deinitialized. + /// - Returns: The updated summary that replaces the parent's current summary. + static func terminate(_ parentSummary: Summary, _ selfSummary: Summary) -> Summary + } + + // Namespace for properties specific to operations reported on + public struct Properties: Sendable { + + /// The total number of files. + public var totalFileCount: TotalFileCount.Type { TotalFileCount.self } + public struct TotalFileCount: Sendable, Property { + + public typealias Value = Int + + public typealias Summary = Int + + public static var key: String { return "Foundation.ProgressManager.Properties.TotalFileCount" } + + public static var defaultValue: Int { return 0 } + + public static var defaultSummary: Int { return 0 } + + public static func reduce(into summary: inout Int, value: Int) { + summary += value + } + + public static func merge(_ summary1: Int, _ summary2: Int) -> Int { + return summary1 + summary2 + } + + public static func terminate(_ parentSummary: Int, _ selfSummary: Int) -> Int { + return parentSummary + selfSummary + } + } + + /// The number of completed files. + public var completedFileCount: CompletedFileCount.Type { CompletedFileCount.self } + public struct CompletedFileCount: Sendable, Property { + + public typealias Value = Int + + public typealias Summary = Int + + public static var key: String { return "Foundation.ProgressManager.Properties.CompletedFileCount" } + + public static var defaultValue: Int { return 0 } + + public static var defaultSummary: Int { return 0 } + + public static func reduce(into summary: inout Int, value: Int) { + summary += value + } + + public static func merge(_ summary1: Int, _ summary2: Int) -> Int { + return summary1 + summary2 + } + + public static func terminate(_ parentSummary: Int, _ selfSummary: Int) -> Int { + return parentSummary + selfSummary + } + } + + /// The total number of bytes. + public var totalByteCount: TotalByteCount.Type { TotalByteCount.self } + public struct TotalByteCount: Sendable, Property { + + public typealias Value = UInt64 + + public typealias Summary = UInt64 + + public static var key: String { return "Foundation.ProgressManager.Properties.TotalByteCount" } + + public static var defaultValue: UInt64 { return 0 } + + public static var defaultSummary: UInt64 { return 0 } + + public static func reduce(into summary: inout UInt64, value: UInt64) { + summary += value + } + + public static func merge(_ summary1: UInt64, _ summary2: UInt64) -> UInt64 { + return summary1 + summary2 + } + + public static func terminate(_ parentSummary: UInt64, _ selfSummary: UInt64) -> UInt64 { + return parentSummary + selfSummary + } + } + + /// The number of completed bytes. + public var completedByteCount: CompletedByteCount.Type { CompletedByteCount.self } + public struct CompletedByteCount: Sendable, Property { + + public typealias Value = UInt64 + + public typealias Summary = UInt64 + + public static var key: String { return "Foundation.ProgressManager.Properties.CompletedByteCount" } + + public static var defaultValue: UInt64 { return 0 } + + public static var defaultSummary: UInt64 { return 0 } + + public static func reduce(into summary: inout UInt64, value: UInt64) { + summary += value + } + + public static func merge(_ summary1: UInt64, _ summary2: UInt64) -> UInt64 { + return summary1 + summary2 + } + + public static func terminate(_ parentSummary: UInt64, _ selfSummary: UInt64) -> UInt64 { + return parentSummary + selfSummary + } + } + + /// The throughput, in bytes per second. + public var throughput: Throughput.Type { Throughput.self } + public struct Throughput: Sendable, Property { + public typealias Value = UInt64 + + public typealias Summary = [UInt64] + + public static var key: String { return "Foundation.ProgressManager.Properties.Throughput" } + + public static var defaultValue: UInt64 { return 0 } + + public static var defaultSummary: [UInt64] { return [] } + + public static func reduce(into summary: inout [UInt64], value: UInt64) { + summary.append(value) + } + + public static func merge(_ summary1: [UInt64], _ summary2: [UInt64]) -> [UInt64] { + return summary1 + summary2 + } + + public static func terminate(_ parentSummary: [UInt64], _ selfSummary: [UInt64]) -> [UInt64] { + return parentSummary + selfSummary + } + } + + /// The amount of time remaining in the processing of files. + public var estimatedTimeRemaining: EstimatedTimeRemaining.Type { EstimatedTimeRemaining.self } + public struct EstimatedTimeRemaining: Sendable, Property { + + public typealias Value = Duration + + public typealias Summary = Duration + + public static var key: String { return "Foundation.ProgressManager.Properties.EstimatedTimeRemaining" } + + public static var defaultValue: Duration { return Duration.seconds(0) } + + public static var defaultSummary: Duration { return Duration.seconds(0) } + + public static func reduce(into summary: inout Duration, value: Duration) { + if summary >= value { + return + } else { + summary = value + } + } + + public static func merge(_ summary1: Duration, _ summary2: Duration) -> Duration { + return max(summary1, summary2) + } + + public static func terminate(_ parentSummary: Duration, _ selfSummary: Duration) -> Duration { + return parentSummary + } + } + + + /// The URL of file being processed. + public var fileURL: FileURL.Type { FileURL.self } + public struct FileURL: Sendable, Property { + + public typealias Value = URL? + + public typealias Summary = [URL?] + + public static var key: String { return "Foundation.ProgressManager.Properties.FileURL" } + + public static var defaultValue: URL? { return nil } + + public static var defaultSummary: [URL?] { return [] } + + public static func reduce(into summary: inout [URL?], value: URL?) { + guard let value else { + return + } + summary.append(value) + } + + public static func merge(_ summary1: [URL?], _ summary2: [URL?]) -> [URL?] { + return summary1 + summary2 + } + + public static func terminate(_ parentSummary: [URL?], _ selfSummary: [URL?]) -> [URL?] { + return parentSummary + } + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Helpers.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Helpers.swift new file mode 100644 index 000000000..ae8c899c5 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Helpers.swift @@ -0,0 +1,664 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +internal import Synchronization +@available(FoundationPreview 6.2, *) +extension ProgressManager { + + internal enum CountType { + case total + case completed + } + + //MARK: Helper Methods for Updating Dirty Path + + internal func getUpdatedIntSummary(property: MetatypeWrapper) -> Int { + return state.withLock { state in + + var value: Int = property.defaultSummary + property.reduce(&value, state.propertiesInt[property] ?? property.defaultValue) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if let childPropertyState = childState.childPropertiesInt[property] { + if childPropertyState.isDirty { + // Dirty, needs to fetch value + if let child = childState.child { + let updatedSummary = child.getUpdatedIntSummary(property: property) + let newChildPropertyState = PropertyStateInt(value: updatedSummary, isDirty: false) + state.children[idx].childPropertiesInt[property] = newChildPropertyState + value = property.merge(value, updatedSummary) + } + } else { + // Not dirty, use value directly + if let _ = childState.child { + value = property.merge(value, childPropertyState.value) + } else { + // TODO: What to do after terminate? Set to nil? + value = property.terminate(value, childPropertyState.value) + } + } + } else { + // Said property doesn't even get cached yet, but children might have been set + if let child = childState.child { + // If there is a child + let childSummary = child.getUpdatedIntSummary(property: property) + let newChildPropertyState = PropertyStateInt(value: childSummary, isDirty: false) + state.children[idx].childPropertiesInt[property] = newChildPropertyState + value = property.merge(value, childSummary) + } + } + } + return value + } + } + + internal func getUpdatedDoubleSummary(property: MetatypeWrapper) -> Double { + return state.withLock { state in + + var value: Double = property.defaultSummary + property.reduce(&value, state.propertiesDouble[property] ?? property.defaultValue) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if let childPropertyState = childState.childPropertiesDouble[property] { + if childPropertyState.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedDoubleSummary(property: property) + let newChildPropertyState = PropertyStateDouble(value: updatedSummary, isDirty: false) + state.children[idx].childPropertiesDouble[property] = newChildPropertyState + value = property.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = property.merge(value, childPropertyState.value) + } else { + value = property.terminate(value, childPropertyState.value) + } + } + } else { + // First fetch of value + if let child = childState.child { + let childSummary = child.getUpdatedDoubleSummary(property: property) + let newChildPropertyState = PropertyStateDouble(value: childSummary, isDirty: false) + state.children[idx].childPropertiesDouble[property] = newChildPropertyState + value = property.merge(value, childSummary) + } + } + } + return value + } + } + + internal func getUpdatedStringSummary(property: MetatypeWrapper) -> [String?] { + return state.withLock { state in + + var value: [String?] = property.defaultSummary + property.reduce(&value, state.propertiesString[property] ?? property.defaultValue) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if let childPropertyState = childState.childPropertiesString[property] { + if childPropertyState.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedStringSummary(property: property) + let newChildPropertyState = PropertyStateString(value: updatedSummary, isDirty: false) + state.children[idx].childPropertiesString[property] = newChildPropertyState + value = property.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = property.merge(value, childPropertyState.value) + } else { + value = property.terminate(value, childPropertyState.value) + } + } + } else { + // First fetch of value + if let child = childState.child { + let childSummary = child.getUpdatedStringSummary(property: property) + let newChildPropertyState = PropertyStateString(value: childSummary, isDirty: false) + state.children[idx].childPropertiesString[property] = newChildPropertyState + value = property.merge(value, childSummary) + } + } + } + return value + } + } + + internal func getUpdatedURLSummary(property: MetatypeWrapper) -> [URL?] { + return state.withLock { state in + + var value: [URL?] = property.defaultSummary + property.reduce(&value, state.propertiesURL[property] ?? property.defaultValue) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if let childPropertyState = childState.childPropertiesURL[property] { + if childPropertyState.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedURLSummary(property: property) + let newChildPropertyState = PropertyStateURL(value: updatedSummary, isDirty: false) + state.children[idx].childPropertiesURL[property] = newChildPropertyState + value = property.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = property.merge(value, childPropertyState.value) + } else { + value = property.terminate(value, childPropertyState.value) + } + } + } else { + // First fetch of value + if let child = childState.child { + let childSummary = child.getUpdatedURLSummary(property: property) + let newChildPropertyState = PropertyStateURL(value: childSummary, isDirty: false) + state.children[idx].childPropertiesURL[property] = newChildPropertyState + value = property.merge(value, childSummary) + } + } + } + return value + } + } + + internal func getUpdatedUInt64Summary(property: MetatypeWrapper) -> [UInt64] { + return state.withLock { state in + + var value: [UInt64] = property.defaultSummary + property.reduce(&value, state.propertiesUInt64[property] ?? property.defaultValue) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if let childPropertyState = childState.childPropertiesUInt64[property] { + if childPropertyState.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedUInt64Summary(property: property) + let newChildPropertyState = PropertyStateThroughput(value: updatedSummary, isDirty: false) + state.children[idx].childPropertiesUInt64[property] = newChildPropertyState + value = property.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = property.merge(value, childPropertyState.value) + } else { + value = property.terminate(value, childPropertyState.value) + } + } + } else { + // First fetch of value + if let child = childState.child { + let childSummary = child.getUpdatedUInt64Summary(property: property) + let newChildPropertyState = PropertyStateThroughput(value: childSummary, isDirty: false) + state.children[idx].childPropertiesUInt64[property] = newChildPropertyState + value = property.merge(value, childSummary) + } + } + } + return value + } + } + + internal func getUpdatedFileCount(type: CountType) -> Int { + switch type { + case .total: + return state.withLock { state in + // Get self's totalFileCount as part of summary + var value: Int = 0 + ProgressManager.Properties.TotalFileCount.reduce(into: &value, value: state.totalFileCount) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.totalFileCount.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedFileCount(type: type) + let newTotalFileCountState = PropertyStateInt(value: updatedSummary, isDirty: false) + state.children[idx].totalFileCount = newTotalFileCountState + value = ProgressManager.Properties.TotalFileCount.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.TotalFileCount.merge(value, childState.totalFileCount.value) + } else { + value = ProgressManager.Properties.TotalFileCount.terminate(value, childState.totalFileCount.value) + } + } + } + return value + } + case .completed: + return state.withLock { state in + // Get self's completedFileCount as part of summary + var value: Int = 0 + ProgressManager.Properties.CompletedFileCount.reduce(into: &value, value: state.completedFileCount) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.completedFileCount.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedFileCount(type: type) + let newCompletedFileCountState = PropertyStateInt(value: updatedSummary, isDirty: false) + state.children[idx].completedFileCount = newCompletedFileCountState + value = ProgressManager.Properties.CompletedFileCount.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.CompletedFileCount.merge(value, childState.completedFileCount.value) + } else { + value = ProgressManager.Properties.CompletedFileCount.terminate(value, childState.completedFileCount.value) + } + } + } + return value + } + } + } + + internal func getUpdatedByteCount(type: CountType) -> UInt64 { + switch type { + case .total: + return state.withLock { state in + // Get self's totalByteCount as part of summary + var value: UInt64 = 0 + ProgressManager.Properties.TotalByteCount.reduce(into: &value, value: state.totalByteCount) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.totalByteCount.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedByteCount(type: type) + let newTotalByteCountState = PropertyStateUInt64(value: updatedSummary, isDirty: false) + state.children[idx].totalByteCount = newTotalByteCountState + value = ProgressManager.Properties.TotalByteCount.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.TotalByteCount.merge(value, childState.totalByteCount.value) + } else { + value = ProgressManager.Properties.TotalByteCount.terminate(value, childState.totalByteCount.value) + } + } + } + return value + } + case .completed: + return state.withLock { state in + // Get self's completedByteCount as part of summary + var value: UInt64 = 0 + ProgressManager.Properties.CompletedByteCount.reduce(into: &value, value: state.completedByteCount) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.completedByteCount.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedByteCount(type: type) + let newCompletedByteCountState = PropertyStateUInt64(value: updatedSummary, isDirty: false) + state.children[idx].completedByteCount = newCompletedByteCountState + value = ProgressManager.Properties.CompletedByteCount.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.CompletedByteCount.merge(value, childState.completedByteCount.value) + } else { + value = ProgressManager.Properties.CompletedByteCount.terminate(value, childState.completedByteCount.value) + } + } + } + return value + } + } + } + + internal func getUpdatedThroughput() -> [UInt64] { + return state.withLock { state in + // Get self's throughput as part of summary + var value = ProgressManager.Properties.Throughput.defaultSummary + ProgressManager.Properties.Throughput.reduce(into: &value, value: state.throughput) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.throughput.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedThroughput() + let newThroughputState = PropertyStateThroughput(value: updatedSummary, isDirty: false) + state.children[idx].throughput = newThroughputState + value = ProgressManager.Properties.Throughput.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.Throughput.merge(value, childState.throughput.value) + } else { + value = ProgressManager.Properties.Throughput.terminate(value, childState.throughput.value) + } + } + } + return value + } + } + + internal func getUpdatedEstimatedTimeRemaining() -> Duration { + return state.withLock { state in + // Get self's estimatedTimeRemaining as part of summary + var value: Duration = Duration.seconds(0) + ProgressManager.Properties.EstimatedTimeRemaining.reduce(into: &value, value: state.estimatedTimeRemaining) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.estimatedTimeRemaining.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedEstimatedTimeRemaining() + let newDurationState = PropertyStateDuration(value: updatedSummary, isDirty: false) + state.children[idx].estimatedTimeRemaining = newDurationState + value = ProgressManager.Properties.EstimatedTimeRemaining.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.EstimatedTimeRemaining.merge(value, childState.estimatedTimeRemaining.value) + } else { + value = ProgressManager.Properties.EstimatedTimeRemaining.terminate(value, childState.estimatedTimeRemaining.value) + } + } + } + return value + } + } + + internal func getUpdatedFileURL() -> [URL?] { + return state.withLock { state in + // Get self's estimatedTimeRemaining as part of summary + var value: [URL?] = ProgressManager.Properties.FileURL.defaultSummary + ProgressManager.Properties.FileURL.reduce(into: &value, value: state.fileURL) + + guard !state.children.isEmpty else { + return value + } + + for (idx, childState) in state.children.enumerated() { + if childState.fileURL.isDirty { + // Update dirty path + if let child = childState.child { + let updatedSummary = child.getUpdatedFileURL() + let newFileURL = PropertyStateURL(value: updatedSummary, isDirty: false) + state.children[idx].fileURL = newFileURL + value = ProgressManager.Properties.FileURL.merge(value, updatedSummary) + } + } else { + if let _ = childState.child { + // Merge non-dirty, updated value + value = ProgressManager.Properties.FileURL.merge(value, childState.fileURL.value) + } else { + value = ProgressManager.Properties.FileURL.terminate(value, childState.fileURL.value) + } + } + } + return value + } + } + + //MARK: Helper Methods for Setting Dirty Paths + + internal func markSelfDirty(property: MetatypeWrapper, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: MetatypeWrapper, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: MetatypeWrapper, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: MetatypeWrapper, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: MetatypeWrapper, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.TotalFileCount.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.CompletedFileCount.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.TotalByteCount.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.CompletedByteCount.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.Throughput.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.EstimatedTimeRemaining.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markSelfDirty(property: ProgressManager.Properties.FileURL.Type, parents: [ParentState]) { + for parentState in parents { + parentState.parent.markChildDirty(property: property, at: parentState.positionInParent) + } + } + + internal func markChildDirty(property: MetatypeWrapper, at position: Int) { + let parents = state.withLock { state in + state.children[position].childPropertiesInt[property]?.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: MetatypeWrapper, at position: Int) { + let parents = state.withLock { state in + state.children[position].childPropertiesDouble[property]?.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: MetatypeWrapper, at position: Int) { + let parents = state.withLock { state in + state.children[position].childPropertiesString[property]?.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: MetatypeWrapper, at position: Int) { + let parents = state.withLock { state in + state.children[position].childPropertiesURL[property]?.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: MetatypeWrapper, at position: Int) { + let parents = state.withLock { state in + state.children[position].childPropertiesUInt64[property]?.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.TotalFileCount.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].totalFileCount.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.CompletedFileCount.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].completedFileCount.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.TotalByteCount.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].totalByteCount.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.CompletedByteCount.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].completedByteCount.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.Throughput.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].throughput.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.EstimatedTimeRemaining.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].estimatedTimeRemaining.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + internal func markChildDirty(property: ProgressManager.Properties.FileURL.Type, at position: Int) { + let parents = state.withLock { state in + state.children[position].fileURL.isDirty = true + return state.parents + } + markSelfDirty(property: property, parents: parents) + } + + //MARK: Method to preserve values of properties upon deinit + internal func setChildDeclaredAdditionalProperties(at position: Int, totalFileCount: Int, completedFileCount: Int, totalByteCount: UInt64, completedByteCount: UInt64, throughput: [UInt64], estimatedTimeRemaining: Duration, fileURL: [URL?], propertiesInt: [MetatypeWrapper: Int], propertiesDouble: [MetatypeWrapper: Double], propertiesString: [MetatypeWrapper: [String?]], propertiesURL: [MetatypeWrapper: [URL?]], propertiesUInt64: [MetatypeWrapper: [UInt64]]) { + state.withLock { state in + state.children[position].totalFileCount = PropertyStateInt(value: totalFileCount, isDirty: false) + state.children[position].completedFileCount = PropertyStateInt(value: completedFileCount, isDirty: false) + state.children[position].totalByteCount = PropertyStateUInt64(value: totalByteCount, isDirty: false) + state.children[position].completedByteCount = PropertyStateUInt64(value: completedByteCount, isDirty: false) + state.children[position].throughput = PropertyStateThroughput(value: throughput, isDirty: false) + state.children[position].estimatedTimeRemaining = PropertyStateDuration(value: estimatedTimeRemaining, isDirty: false) + state.children[position].fileURL = PropertyStateURL(value: fileURL, isDirty: false) + + for (propertyKey, propertyValue) in propertiesInt { + state.children[position].childPropertiesInt[propertyKey] = PropertyStateInt(value: propertyValue, isDirty: false) + } + + for (propertyKey, propertyValue) in propertiesDouble { + state.children[position].childPropertiesDouble[propertyKey] = PropertyStateDouble(value: propertyValue, isDirty: false) + } + + for (propertyKey, propertyValue) in propertiesString { + state.children[position].childPropertiesString[propertyKey] = PropertyStateString(value: propertyValue, isDirty: false) + } + + for (propertyKey, propertyValue) in propertiesURL { + state.children[position].childPropertiesURL[propertyKey] = PropertyStateURL(value: propertyValue, isDirty: false) + } + + for (propertyKey, propertyValue) in propertiesUInt64 { + state.children[position].childPropertiesUInt64[propertyKey] = PropertyStateThroughput(value: propertyValue, isDirty: false) + } + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift new file mode 100644 index 000000000..0e09af181 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift @@ -0,0 +1,241 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +internal import Synchronization + +@available(FoundationPreview 6.2, *) +extension ProgressManager { + + internal struct MetatypeWrapper: Hashable, Equatable, Sendable { + + let reduce: @Sendable (inout S, V) -> () + let merge: @Sendable (S, S) -> S + let terminate: @Sendable (S, S) -> S + + let defaultValue: V + let defaultSummary: S + + let key: String + + init(_ argument: P.Type) where P.Value == V, P.Summary == S { + reduce = P.reduce + merge = P.merge + terminate = P.terminate + defaultValue = P.defaultValue + defaultSummary = P.defaultSummary + key = P.key + } + + func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + + static func == (lhs: ProgressManager.MetatypeWrapper, rhs: ProgressManager.MetatypeWrapper) -> Bool { + lhs.key == rhs.key + } + } + + internal struct PropertyStateInt { + var value: Int + var isDirty: Bool + } + + internal struct PropertyStateUInt64 { + var value: UInt64 + var isDirty: Bool + } + + internal struct PropertyStateThroughput { + var value: [UInt64] + var isDirty: Bool + } + + internal struct PropertyStateDuration { + var value: Duration + var isDirty: Bool + } + + internal struct PropertyStateDouble { + var value: Double + var isDirty: Bool + } + + internal struct PropertyStateString { + var value: [String?] + var isDirty: Bool + } + + internal struct PropertyStateURL { + var value: [URL?] + var isDirty: Bool + } + + internal struct ChildState { + weak var child: ProgressManager? + var portionOfTotal: Int + var childFraction: ProgressFraction + var isDirty: Bool + var totalFileCount: PropertyStateInt + var completedFileCount: PropertyStateInt + var totalByteCount: PropertyStateUInt64 + var completedByteCount: PropertyStateUInt64 + var throughput: PropertyStateThroughput + var estimatedTimeRemaining: PropertyStateDuration + var fileURL: PropertyStateURL + var childPropertiesInt: [MetatypeWrapper: PropertyStateInt] + var childPropertiesDouble: [MetatypeWrapper: PropertyStateDouble] + var childPropertiesString: [MetatypeWrapper: PropertyStateString] + var childPropertiesURL: [MetatypeWrapper: PropertyStateURL] + var childPropertiesUInt64: [MetatypeWrapper: PropertyStateThroughput] + } + + internal struct ParentState { + var parent: ProgressManager + var positionInParent: Int + } + + internal struct State { + var selfFraction: ProgressFraction + var overallFraction: ProgressFraction { + var overallFraction = selfFraction + for childState in children { + if let _ = childState.child { + if !childState.childFraction.isFinished { + let multiplier = ProgressFraction(completed: childState.portionOfTotal, total: selfFraction.total) + if let additionalFraction = multiplier * childState.childFraction { + overallFraction = overallFraction + additionalFraction + } + } + } + } + return overallFraction + } + var children: [ChildState] + var parents: [ParentState] + var totalFileCount: Int + var completedFileCount: Int + var totalByteCount: UInt64 + var completedByteCount: UInt64 + var throughput: UInt64 + var estimatedTimeRemaining: Duration + var fileURL: URL? + var propertiesInt: [MetatypeWrapper: Int] + var propertiesDouble: [MetatypeWrapper: Double] + var propertiesString: [MetatypeWrapper: String?] + var propertiesURL: [MetatypeWrapper: URL?] + var propertiesUInt64: [MetatypeWrapper: UInt64] +#if FOUNDATION_FRAMEWORK + var observers: [@Sendable (ObserverState) -> Void] + var interopType: InteropType? +#endif + + /// Returns nil if `self` was instantiated without total units; + /// returns a `Int` value otherwise. + internal func getTotalCount() -> Int? { +#if FOUNDATION_FRAMEWORK + if let interopTotalCount = interopType?.totalCount { + return interopTotalCount + } +#endif + return selfFraction.total + } + + /// Returns 0 if `self` has `nil` total units; + /// returns a `Int` value otherwise. + internal mutating func getCompletedCount() -> Int { +#if FOUNDATION_FRAMEWORK + if let interopCompletedCount = interopType?.completedCount { + return interopCompletedCount + } +#endif + updateChildrenProgressFraction() + return selfFraction.completed + } + + internal mutating func getFractionCompleted() -> Double { +#if FOUNDATION_FRAMEWORK + if let interopFractionCompleted = interopType?.fractionCompleted { + return interopFractionCompleted + } +#endif + updateChildrenProgressFraction() + return overallFraction.fractionCompleted + } + + internal func getIsIndeterminate() -> Bool { +#if FOUNDATION_FRAMEWORK + if let interopIsIndeterminate = interopType?.isIndeterminate { + return interopIsIndeterminate + } +#endif + return selfFraction.isIndeterminate + } + + internal mutating func getIsFinished() -> Bool { +#if FOUNDATION_FRAMEWORK + if let interopIsFinished = interopType?.isFinished { + return interopIsFinished + } +#endif + updateChildrenProgressFraction() + return selfFraction.isFinished + } + + internal mutating func updateChildrenProgressFraction() { + guard !children.isEmpty else { + return + } + for (idx, childState) in children.enumerated() { + if childState.isDirty { + if let child = childState.child { + let updatedProgressFraction = child.getUpdatedProgressFraction() + children[idx].childFraction = updatedProgressFraction + if updatedProgressFraction.isFinished { + selfFraction.completed += children[idx].portionOfTotal + } + } else { + selfFraction.completed += children[idx].portionOfTotal + } + children[idx].isDirty = false + } + } + } + + internal mutating func complete(by count: Int) { + selfFraction.completed += count + +#if FOUNDATION_FRAMEWORK + switch interopType { + case .interopObservation(let observation): + observation.subprogressBridge?.manager.notifyObservers( + with: .fractionUpdated( + totalCount: selfFraction.total ?? 0, + completedCount: selfFraction.completed + ) + ) + + if let _ = observation.reporterBridge { + notifyObservers( + with: .fractionUpdated( + totalCount: selfFraction.total ?? 0, + completedCount: selfFraction.completed + ) + ) + } + case .interopMirror: + break + default: + break + } +#endif + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift new file mode 100644 index 000000000..0c2401561 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift @@ -0,0 +1,415 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Observation +internal import Synchronization + +#if canImport(CollectionsInternal) +internal import CollectionsInternal +#elseif canImport(OrderedCollections) +internal import OrderedCollections +#elseif canImport(_FoundationCollections) +internal import _FoundationCollections +#endif + +@available(FoundationPreview 6.2, *) +/// An object that conveys ongoing progress to the user for a specified task. +@Observable public final class ProgressManager: Sendable { + + internal let state: Mutex + + /// The total units of work. + public var totalCount: Int? { + _$observationRegistrar.access(self, keyPath: \.totalCount) + return state.withLock { state in + state.getTotalCount() + } + } + + /// The completed units of work. + /// If `self` is indeterminate, the value will be 0. + public var completedCount: Int { + _$observationRegistrar.access(self, keyPath: \.completedCount) + return state.withLock { state in + state.getCompletedCount() + } + } + + /// The proportion of work completed. + /// This takes into account the fraction completed in its children instances if children are present. + /// If `self` is indeterminate, the value will be 0. + public var fractionCompleted: Double { + _$observationRegistrar.access(self, keyPath: \.fractionCompleted) + return state.withLock { state in + state.getFractionCompleted() + } + } + + /// The state of initialization of `totalCount`. + /// If `totalCount` is `nil`, the value will be `true`. + public var isIndeterminate: Bool { + _$observationRegistrar.access(self, keyPath: \.isIndeterminate) + return state.withLock { state in + state.getIsIndeterminate() + } + } + + /// The state of completion of work. + /// If `completedCount` >= `totalCount`, the value will be `true`. + public var isFinished: Bool { + _$observationRegistrar.access(self, keyPath: \.isFinished) + return state.withLock { state in + state.getIsFinished() + } + } + + /// A `ProgressReporter` instance, used for providing read-only observation of progress updates or composing into other `ProgressManager`s. + public var reporter: ProgressReporter { + return .init(manager: self) + } + +#if FOUNDATION_FRAMEWORK + internal init(total: Int?, completed: Int?, subprogressBridge: SubprogressBridge?) { + let state = State( + selfFraction: ProgressFraction(completed: completed ?? 0, total: total), + children: [], + parents: [], + totalFileCount: ProgressManager.Properties.TotalFileCount.defaultValue, + completedFileCount: ProgressManager.Properties.CompletedFileCount.defaultValue, + totalByteCount: ProgressManager.Properties.TotalByteCount.defaultValue, + completedByteCount: ProgressManager.Properties.CompletedByteCount.defaultValue, + throughput: ProgressManager.Properties.Throughput.defaultValue, + estimatedTimeRemaining: ProgressManager.Properties.EstimatedTimeRemaining.defaultValue, + fileURL: ProgressManager.Properties.FileURL.defaultValue, + propertiesInt: [:], + propertiesDouble: [:], + propertiesString: [:], + propertiesURL: [:], + propertiesUInt64: [:], + observers: [], + interopType: .interopObservation(InteropObservation(subprogressBridge: subprogressBridge)) + ) + self.state = Mutex(state) + } +#else + internal init(total: Int?, completed: Int?) { + let state = State( + selfFraction: ProgressFraction(completed: completed ?? 0, total: total), + children: [], + parents: [], + totalFileCount: ProgressManager.Properties.TotalFileCount.defaultValue, + completedFileCount: ProgressManager.Properties.CompletedFileCount.defaultValue, + totalByteCount: ProgressManager.Properties.TotalByteCount.defaultValue, + completedByteCount: ProgressManager.Properties.CompletedByteCount.defaultValue, + throughput: ProgressManager.Properties.Throughput.defaultValue, + estimatedTimeRemaining: ProgressManager.Properties.EstimatedTimeRemaining.defaultValue, + fileURL: ProgressManager.Properties.FileURL.defaultValue, + propertiesInt: [:], + propertiesDouble: [:], + propertiesString: [:], + propertiesURL: [:], + propertiesUInt64: [:], + ) + self.state = Mutex(state) + } +#endif + + /// Initializes `self` with `totalCount`. + /// + /// If `totalCount` is set to `nil`, `self` is indeterminate. + /// - Parameter totalCount: Total units of work. + public convenience init(totalCount: Int?) { + #if FOUNDATION_FRAMEWORK + self.init( + total: totalCount, + completed: nil, + subprogressBridge: nil + ) + #else + self.init( + total: totalCount, + completed: nil, + ) + #endif + } + + /// Returns a `Subprogress` representing a portion of `self` which can be passed to any method that reports progress. + /// + /// If the `Subprogress` is not converted into a `ProgressManager` (for example, due to an error or early return), + /// then the assigned count is marked as completed in the parent `ProgressManager`. + /// + /// - Parameter count: Units, which is a portion of `totalCount`delegated to an instance of `Subprogress`. + /// - Returns: A `Subprogress` instance. + public func subprogress(assigningCount portionOfParent: Int) -> Subprogress { + precondition(portionOfParent > 0, "Giving out zero units is not a valid operation.") + let subprogress = Subprogress(parent: self, portionOfParent: portionOfParent) + return subprogress + } + + /// Adds a `ProgressReporter` as a child, with its progress representing a portion of `self`'s progress. + /// - Parameters: + /// - reporter: A `ProgressReporter` instance. + /// - count: Units, which is a portion of `totalCount`delegated to an instance of `Subprogress`. + public func assign(count: Int, to reporter: ProgressReporter) { + precondition(isCycle(reporter: reporter) == false, "Creating a cycle is not allowed.") + + let actualManager = reporter.manager + + let position = self.addChild(child: actualManager, portion: count, childFraction: actualManager.getProgressFraction()) + actualManager.addParent(parent: self, positionInParent: position) + } + + /// Increases `completedCount` by `count`. + /// - Parameter count: Units of work. + public func complete(count: Int) { + let parents: [ParentState]? = state.withLock { state in + guard state.selfFraction.completed != (state.selfFraction.completed + count) else { + return nil + } + + state.complete(by: count) + + return state.parents + } + if let parents = parents { + markSelfDirty(parents: parents) + } + } + + //MARK: Internal vars to make additional properties observable + internal var totalFileCount: Int { + _$observationRegistrar.access(self, keyPath: \.totalFileCount) + return getUpdatedFileCount(type: .total) + } + + internal var completedFileCount: Int { + _$observationRegistrar.access(self, keyPath: \.completedFileCount) + return getUpdatedFileCount(type: .completed) + } + + //MARK: Fractional Properties Methods + internal func getProgressFraction() -> ProgressFraction { + return state.withLock { state in + return state.selfFraction + + } + } + + //MARK: Fractional Calculation methods + internal func markSelfDirty() { + let parents = state.withLock { state in + return state.parents + } + markSelfDirty(parents: parents) + } + + internal func markSelfDirty(parents: [ParentState]) { + _$observationRegistrar.withMutation(of: self, keyPath: \.fractionCompleted) { + if parents.count > 0 { + for parentState in parents { + parentState.parent.markChildDirty(at: parentState.positionInParent) + } + } + } + } + + private func markChildDirty(at position: Int) { + let parents: [ParentState]? = state.withLock { state in + guard !state.children[position].isDirty else { + return nil + } + state.children[position].isDirty = true + return state.parents + } + if let parents = parents { + markSelfDirty(parents: parents) + } + } + + internal func getUpdatedProgressFraction() -> ProgressFraction { + return state.withLock { state in + state.updateChildrenProgressFraction() + return state.overallFraction + } + } + + //MARK: Parent - Child Relationship Methods + internal func addChild(child: ProgressManager, portion: Int, childFraction: ProgressFraction) -> Int { + let (index, parents) = state.withLock { state in + let childState = ChildState(child: child, + portionOfTotal: portion, + childFraction: childFraction, + isDirty: true, + totalFileCount: PropertyStateInt(value: ProgressManager.Properties.TotalFileCount.defaultSummary, isDirty: false), + completedFileCount: PropertyStateInt(value: ProgressManager.Properties.CompletedFileCount.defaultSummary, isDirty: false), + totalByteCount: PropertyStateUInt64(value: ProgressManager.Properties.TotalByteCount.defaultSummary, isDirty: false), + completedByteCount: PropertyStateUInt64(value: ProgressManager.Properties.CompletedByteCount.defaultSummary, isDirty: false), + throughput: PropertyStateThroughput(value: ProgressManager.Properties.Throughput.defaultSummary, isDirty: false), + estimatedTimeRemaining: PropertyStateDuration(value: ProgressManager.Properties.EstimatedTimeRemaining.defaultSummary, isDirty: false), + fileURL: PropertyStateURL(value: ProgressManager.Properties.FileURL.defaultSummary, isDirty: false), + childPropertiesInt: [:], + childPropertiesDouble: [:], + childPropertiesString: [:], + childPropertiesURL: [:], + childPropertiesUInt64: [:]) + state.children.append(childState) + return (state.children.count - 1, state.parents) + } + // Mark dirty all the way up to the root so that if the branch was marked not dirty right before this it will be marked dirty again (for optimization to work) + markSelfDirty(parents: parents) + return index + } + + internal func addParent(parent: ProgressManager, positionInParent: Int) { + state.withLock { state in + let parentState = ParentState(parent: parent, positionInParent: positionInParent) + state.parents.append(parentState) + } + } + + // MARK: Cycle Detection Methods + internal func isCycle(reporter: ProgressReporter, visited: Set = []) -> Bool { + if reporter.manager === self { + return true + } + + let updatedVisited = visited.union([self]) + + return state.withLock { state in + for parentState in state.parents { + if !updatedVisited.contains(parentState.parent) { + if (parentState.parent.isCycle(reporter: reporter, visited: updatedVisited)) { + return true + } + } + } + return false + } + } + + internal func isCycleInterop(reporter: ProgressReporter, visited: Set = []) -> Bool { + return state.withLock { state in + for parentState in state.parents { + if !visited.contains(parentState.parent) { + if (parentState.parent.isCycle(reporter: reporter, visited: visited)) { + return true + } + } + } + return false + } + } + + deinit { + + let (propertiesInt, propertiesDouble, propertiesString, propertiesURL, propertiesUInt64, parents) = state.withLock { state in + return (state.propertiesInt, state.propertiesDouble, state.propertiesString, state.propertiesURL, state.propertiesUInt64, state.parents) + } + + var finalSummaryInt: [MetatypeWrapper: Int] = [:] + for property in propertiesInt.keys { + let updatedSummary = self.getUpdatedIntSummary(property: property) + finalSummaryInt[property] = updatedSummary + } + + var finalSummaryDouble: [MetatypeWrapper: Double] = [:] + for property in propertiesDouble.keys { + let updatedSummary = self.getUpdatedDoubleSummary(property: property) + finalSummaryDouble[property] = updatedSummary + } + + var finalSummaryString: [MetatypeWrapper: [String?]] = [:] + for property in propertiesString.keys { + let updatedSummary = self.getUpdatedStringSummary(property: property) + finalSummaryString[property] = updatedSummary + } + + var finalSummaryURL: [MetatypeWrapper: [URL?]] = [:] + for property in propertiesURL.keys { + let updatedSummary = self.getUpdatedURLSummary(property: property) + finalSummaryURL[property] = updatedSummary + } + + var finalSummaryUInt64: [MetatypeWrapper: [UInt64]] = [:] + for property in propertiesUInt64.keys { + let updatedSummary = self.getUpdatedUInt64Summary(property: property) + finalSummaryUInt64[property] = updatedSummary + } + + let totalFileCount = self.getUpdatedFileCount(type: .total) + let completedFileCount = self.getUpdatedFileCount(type: .completed) + let totalByteCount = self.getUpdatedByteCount(type: .total) + let completedByteCount = self.getUpdatedByteCount(type: .completed) + let throughput = self.getUpdatedThroughput() + let estimatedTimeRemaining = self.getUpdatedEstimatedTimeRemaining() + let fileURL = self.getUpdatedFileURL() + + if !isFinished { + markSelfDirty(parents: parents) + } + + for parentState in parents { + parentState.parent.setChildDeclaredAdditionalProperties( + at: parentState.positionInParent, + totalFileCount: totalFileCount, + completedFileCount: completedFileCount, + totalByteCount: totalByteCount, + completedByteCount: completedByteCount, + throughput: throughput, + estimatedTimeRemaining: estimatedTimeRemaining, + fileURL: fileURL, + propertiesInt: finalSummaryInt, + propertiesDouble: finalSummaryDouble, + propertiesString: finalSummaryString, + propertiesURL: finalSummaryURL, + propertiesUInt64: finalSummaryUInt64 + ) + } + } +} + +@available(FoundationPreview 6.2, *) +extension ProgressManager: Hashable, Equatable { + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + /// Returns `true` if pointer of `lhs` is equal to pointer of `rhs`. + public static func ==(lhs: ProgressManager, rhs: ProgressManager) -> Bool { + return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } +} + +@available(FoundationPreview 6.2, *) +extension ProgressManager: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + return """ + Class Name: ProgressManager + Object Identifier: \(ObjectIdentifier(self)) + totalCount: \(String(describing: totalCount)) + completedCount: \(completedCount) + fractionCompleted: \(fractionCompleted) + isIndeterminate: \(isIndeterminate) + isFinished: \(isFinished) + totalFileCount: \(summary(of: ProgressManager.Properties.TotalFileCount.self)) + completedFileCount: \(summary(of: ProgressManager.Properties.CompletedFileCount.self)) + totalByteCount: \(summary(of: ProgressManager.Properties.TotalByteCount.self)) + completedByteCount: \(summary(of: ProgressManager.Properties.CompletedByteCount.self)) + throughput: \(summary(of: ProgressManager.Properties.Throughput.self)) + estimatedTimeRemaining: \(summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self)) + """ + } + + public var debugDescription: String { + return self.description + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift b/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift new file mode 100644 index 000000000..417047459 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift @@ -0,0 +1,194 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Observation + +@available(FoundationPreview 6.2, *) +/// ProgressReporter is a wrapper for ProgressManager that carries information about ProgressManager. +/// +/// It is read-only and can be added as a child of another ProgressManager. +@Observable public final class ProgressReporter: Sendable, Hashable, Equatable, CustomStringConvertible, CustomDebugStringConvertible { + + /// The total units of work. + public var totalCount: Int? { + manager.totalCount + } + + /// The completed units of work. + /// If `self` is indeterminate, the value will be 0. + public var completedCount: Int { + manager.completedCount + } + + /// The proportion of work completed. + /// This takes into account the fraction completed in its children instances if children are present. + /// If `self` is indeterminate, the value will be 0. + public var fractionCompleted: Double { + manager.fractionCompleted + } + + /// The state of initialization of `totalCount`. + /// If `totalCount` is `nil`, the value will be `true`. + public var isIndeterminate: Bool { + manager.isIndeterminate + } + + /// The state of completion of work. + /// If `completedCount` >= `totalCount`, the value will be `true`. + public var isFinished: Bool { + manager.isFinished + } + + public var description: String { + return """ + Class Name: ProgressReporter + Object Identifier: \(ObjectIdentifier(self)) + progressManager: \(manager) + totalCount: \(String(describing: totalCount)) + completedCount: \(completedCount) + fractionCompleted: \(fractionCompleted) + isIndeterminate: \(isIndeterminate) + isFinished: \(isFinished) + totalFileCount: \(summary(of: ProgressManager.Properties.TotalFileCount.self)) + completedFileCount: \(summary(of: ProgressManager.Properties.CompletedFileCount.self)) + totalByteCount: \(summary(of: ProgressManager.Properties.TotalByteCount.self)) + completedByteCount: \(summary(of: ProgressManager.Properties.CompletedByteCount.self)) + throughput: \(summary(of: ProgressManager.Properties.Throughput.self)) + estimatedTimeRemaining: \(summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self)) + """ + } + + public var debugDescription: String { + return self.description + } + + /// Reads properties that convey additional information about progress. + public func withProperties( + _ closure: (sending ProgressManager.Values) throws(E) -> sending T + ) throws(E) -> T { + return try manager.getProperties(closure) + } + + /// Returns a summary for the specified integer property across the progress subtree. + /// + /// This method aggregates the values of a custom integer property from the underlying progress manager + /// and all its children, returning a consolidated summary value. + /// + /// - Parameter property: The type of the double property to summarize. Must be a property + /// where both the value and summary types are `Int`. + /// - Returns: The aggregated summary value for the specified property across the entire subtree. + public func summary(of property: P.Type) -> Int where P.Value == Int, P.Summary == Int { + manager.summary(of: property) + } + + /// Returns a summary for the specified double property across the progress subtree. + /// + /// This method aggregates the values of a custom double property from the underlying progress manager + /// and all its children, returning a consolidated summary value. + /// + /// - Parameter property: The type of the double property to summarize. Must be a property + /// where both the value and summary types are `Double`. + /// - Returns: The aggregated summary value for the specified property across the entire subtree. + public func summary(of property: P.Type) -> Double where P.Value == Double, P.Summary == Double { + manager.summary(of: property) + } + + /// Returns a summary for the specified string property across the progress subtree. + /// + /// This method aggregates the values of a custom string property from the underlying progress manager + /// and all its children, returning a consolidated summary value. + /// + /// - Parameter property: The type of the string property to summarize. Must be a property + /// where both the value and summary types are `String`. + /// - Returns: The aggregated summary value for the specified property across the entire subtree. + public func summary(of property: P.Type) -> [String?] where P.Value == String?, P.Summary == [String?] { + return manager.summary(of: property) + } + + public func summary(of property: P.Type) -> [URL?] where P.Value == URL?, P.Summary == [URL?] { + return manager.summary(of: property) + } + + public func summary(of property: P.Type) -> [UInt64] where P.Value == UInt64, P.Summary == [UInt64] { + return manager.summary(of: property) + } + + /// Returns the total file count across the progress subtree. + /// + /// - Parameter property: The `TotalFileCount` property type. + /// - Returns: The sum of all total file counts across the entire progress subtree. + public func summary(of property: ProgressManager.Properties.TotalFileCount.Type) -> Int { + return manager.summary(of: property) + } + + /// Returns the completed file count across the progress subtree. + /// + /// - Parameter property: The `CompletedFileCount` property type. + /// - Returns: The sum of all completed file counts across the entire progress subtree. + public func summary(of property: ProgressManager.Properties.CompletedFileCount.Type) -> Int { + manager.summary(of: property) + } + + /// Returns the total byte count across the progress subtree. + /// + /// - Parameter property: The `TotalByteCount` property type. + /// - Returns: The sum of all total byte counts across the entire progress subtree, in bytes. + public func summary(of property: ProgressManager.Properties.TotalByteCount.Type) -> UInt64 { + manager.summary(of: property) + } + + /// Returns the completed byte count across the progress subtree. + /// + /// - Parameter property: The `CompletedByteCount` property type. + /// - Returns: The sum of all completed byte counts across the entire progress subtree, in bytes. + public func summary(of property: ProgressManager.Properties.CompletedByteCount.Type) -> UInt64 { + manager.summary(of: property) + } + + /// Returns the average throughput across the progress subtree. + /// + /// - Parameter property: The `Throughput` property type. + /// - Returns: The average throughput across the entire progress subtree, in bytes per second. + public func summary(of property: ProgressManager.Properties.Throughput.Type) -> [UInt64] { + manager.summary(of: property) + } + + /// Returns the maximum estimated time remaining for completion across the progress subtree. + /// + /// - Parameter property: The `EstimatedTimeRemaining` property type. + /// - Returns: The estimated duration until completion for the entire progress subtree. + public func summary(of property: ProgressManager.Properties.EstimatedTimeRemaining.Type) -> Duration { + manager.summary(of: property) + } + + /// Returns all file URLs being processed across the progress subtree. + /// + /// - Parameter property: The `FileURL` property type. + /// - Returns: An array containing all file URLs across the entire progress subtree. + public func summary(of property: ProgressManager.Properties.FileURL.Type) -> [URL?] { + manager.summary(of: property) + } + + internal let manager: ProgressManager + + internal init(manager: ProgressManager) { + self.manager = manager + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + public static func == (lhs: ProgressReporter, rhs: ProgressReporter) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/Subprogress.swift b/Sources/FoundationEssentials/ProgressManager/Subprogress.swift new file mode 100644 index 000000000..e1c6eacdc --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/Subprogress.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +@available(FoundationPreview 6.2, *) +/// Subprogress is a nested ~Copyable struct used to establish parent-child relationship between two instances of ProgressManager. +/// +/// Subprogress is returned from a call to `subprogress(assigningCount:)` by a parent ProgressManager. +/// A child ProgressManager is then returned by calling`manager(totalCount:)` on a Subprogress. +public struct Subprogress: ~Copyable, Sendable { + internal var parent: ProgressManager + internal var portionOfParent: Int + internal var isInitializedToProgressReporter: Bool +#if FOUNDATION_FRAMEWORK + internal var subprogressBridge: SubprogressBridge? +#endif + +#if FOUNDATION_FRAMEWORK + internal init(parent: ProgressManager, portionOfParent: Int, subprogressBridge: SubprogressBridge? = nil) { + self.parent = parent + self.portionOfParent = portionOfParent + self.isInitializedToProgressReporter = false + self.subprogressBridge = subprogressBridge + } +#else + internal init(parent: ProgressManager, portionOfParent: Int) { + self.parent = parent + self.portionOfParent = portionOfParent + self.isInitializedToProgressReporter = false + } +#endif + + /// Instantiates a ProgressManager which is a child to the parent from which `self` is returned. + /// - Parameter totalCount: Total count of returned child `ProgressManager` instance. + /// - Returns: A `ProgressManager` instance. + public consuming func start(totalCount: Int?) -> ProgressManager { + isInitializedToProgressReporter = true + +#if FOUNDATION_FRAMEWORK + let childManager = ProgressManager( + total: totalCount, + completed: nil, + subprogressBridge: subprogressBridge + ) + + guard subprogressBridge == nil else { + subprogressBridge?.manager.setInteropChild(interopMirror: childManager) + return childManager + } +#else + let childManager = ProgressManager( + total: totalCount, + completed: nil + ) +#endif + + let position = parent.addChild( + child: childManager, + portion: portionOfParent, + childFraction: childManager.getProgressFraction() + ) + childManager.addParent( + parent: parent, + positionInParent: position + ) + + return childManager + } + + deinit { + if !self.isInitializedToProgressReporter { + parent.complete(count: portionOfParent) + } + } +} diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressFractionTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressFractionTests.swift new file mode 100644 index 000000000..49728d456 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressFractionTests.swift @@ -0,0 +1,167 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Testing + +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#else +@testable import FoundationEssentials +#endif // FOUNDATION_FRAMEWORK + +@Suite("Progress Fraction", .tags(.progressManager)) struct ProgressFractionTests { + @Test func equal() { + let f1 = ProgressFraction(completed: 5, total: 10) + let f2 = ProgressFraction(completed: 100, total: 200) + + #expect(f1 == f2) + + let f3 = ProgressFraction(completed: 3, total: 10) + #expect(f1 != f3) + + let f4 = ProgressFraction(completed: 5, total: 10) + #expect(f1 == f4) + } + + @Test func addSame() { + let f1 = ProgressFraction(completed: 5, total: 10) + let f2 = ProgressFraction(completed: 3, total: 10) + + let r = f1 + f2 + #expect(r.completed == 8) + #expect(r.total == 10) + } + + @Test func addDifferent() { + let f1 = ProgressFraction(completed: 5, total: 10) + let f2 = ProgressFraction(completed : 300, total: 1000) + + let r = f1 + f2 + #expect(r.completed == 800) + #expect(r.total == 1000) + } + + @Test func subtract() { + let f1 = ProgressFraction(completed: 5, total: 10) + let f2 = ProgressFraction(completed: 3, total: 10) + + let r = f1 - f2 + #expect(r.completed == 2) + #expect(r.total == 10) + } + + @Test func multiply() { + let f1 = ProgressFraction(completed: 5, total: 10) + let f2 = ProgressFraction(completed: 1, total: 2) + + let r = f1 * f2 + #expect(r?.completed == 5) + #expect(r?.total == 20) + } + + @Test func simplify() { + let f1 = ProgressFraction(completed: 5, total: 10) + let f2 = ProgressFraction(completed: 3, total: 10) + + let r = (f1 + f2).simplified() + + #expect(r?.completed == 4) + #expect(r?.total == 5) + } + + @Test func overflow() { + // These prime numbers are problematic for overflowing + let denominators : [Int] = [5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 69] + + var f1 = ProgressFraction(completed: 1, total: 3) + for d in denominators { + f1 = f1 + ProgressFraction(completed: 1, total: d) + } + + let fractionResult = f1.fractionCompleted + var expectedResult = 1.0 / 3.0 + for d in denominators { + expectedResult = expectedResult + 1.0 / Double(d) + } + #expect(abs(fractionResult - expectedResult) < 0.00001) + } + + @Test func addOverflow() { + // These prime numbers are problematic for overflowing + let denominators : [Int] = [5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 69] + var f1 = ProgressFraction(completed: 1, total: 3) + for d in denominators { + f1 = f1 + ProgressFraction(completed: 1, total: d) + } + + // f1 should be in overflow + #expect(f1.overflowed) + + let f2 = ProgressFraction(completed: 1, total: 4) + f1 + + // f2 should also be in overflow + #expect(f2.overflowed) + + // And it should have completed value of about 1.0/4.0 + f1.fractionCompleted + let expected = (1.0 / 4.0) + f1.fractionCompleted + + #expect(abs(expected - f2.fractionCompleted) < 0.00001) + } + +#if _pointerBitWidth(_64) // These tests assumes Int is Int64 + @Test func addAndSubtractOverflow() { + let f1 = ProgressFraction(completed: 48, total: 60) + let f2 = ProgressFraction(completed: 5880, total: 7200) + let f3 = ProgressFraction(completed: 7048893638467736640, total: 8811117048084670800) + + let result1 = (f3 - f1) + f2 + #expect(result1.completed > 0) + + let result2 = (f3 - f2) + f1 + #expect(result2.completed < 60) + } + + @Test func subtractOverflow() { + let f1 = ProgressFraction(completed: 9855, total: 225066) + let f2 = ProgressFraction(completed: 14985363210613129, total: 56427817205760000) + + let result = f2 - f1 + #expect(abs(Double(result.completed) / Double(result.total!) - 0.2217) < 0.01) + } + + @Test func multiplyOverflow() { + let f1 = ProgressFraction(completed: 4294967279, total: 4294967291) + let f2 = ProgressFraction(completed: 4294967279, total: 4294967291) + + let result = f1 * f2 + #expect(abs(Double(result!.completed) / Double(result!.total!) - 1.0) < 0.01) + } +#endif + + @Test func fractionFromDouble() { + let d = 4.25 // exactly representable in binary + let f1 = ProgressFraction(double: d) + + let simplified = f1.simplified() + #expect(simplified?.completed == 17) + #expect(simplified?.total == 4) + } + + @Test func unnecessaryOverflow() { + // just because a fraction has a large denominator doesn't mean it needs to overflow + let f1 = ProgressFraction(completed: (Int.max - 1) / 2, total: Int.max - 1) + let f2 = ProgressFraction(completed: 1, total: 16) + + let r = f1 + f2 + #expect(!r.overflowed) + } +} diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerInteropTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerInteropTests.swift new file mode 100644 index 000000000..87cd6efad --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerInteropTests.swift @@ -0,0 +1,332 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import Testing + +#if FOUNDATION_FRAMEWORK +@testable import Foundation + +/// Unit tests for interop methods that support building Progress trees with both Progress and ProgressManager +@Suite("Progress Manager Interop", .tags(.progressManager)) struct ProgressManagerInteropTests { + func doSomethingWithProgress() async -> Progress { + let p = Progress(totalUnitCount: 2) + return p + } + + func doSomething(subprogress: consuming Subprogress?) async { + let manager = subprogress?.start(totalCount: 4) + manager?.complete(count: 2) + manager?.complete(count: 2) + } + + // MARK: Progress - Subprogress Interop + @Test func interopProgressParentProgressManagerChild() async throws { + // Initialize a Progress Parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let p1 = await doSomethingWithProgress() + overall.addChild(p1, withPendingUnitCount: 5) + + let _ = await Task { + p1.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p1.completedUnitCount = 2 + }.value + + // Check if Progress values propagate to Progress parent + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedUnitCount == 5) + + // Add ProgressManager as Child + let p2 = overall.makeChild(withPendingUnitCount: 5) + await doSomething(subprogress: p2) + + // Check if ProgressManager values propagate to Progress parent + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedUnitCount == 10) + } + + @Test func interopProgressParentProgressManagerGrandchild() async throws { + // Structure: Progress with two Progress children, one of the children has a ProgressManager child + let overall = Progress.discreteProgress(totalUnitCount: 10) + + let p1 = await doSomethingWithProgress() + overall.addChild(p1, withPendingUnitCount: 5) + + let _ = await Task.detached { + p1.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p1.completedUnitCount = 2 + }.value + + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedUnitCount == 5) + + let p2 = Progress(totalUnitCount: 1, parent: overall, pendingUnitCount: 5) + + await doSomething(subprogress: p2.makeChild(withPendingUnitCount: 1)) + + // Check if ProgressManager values propagate to Progress parent + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedUnitCount == 10) + } + + @Test func interopProgressParentProgressManagerGrandchildAndProgressGrandchild() async throws { + // Structure: Progress with two Progress children, one of the children has a ProgressManager child and a Progress child + let overall = Progress.discreteProgress(totalUnitCount: 10) + + let p1 = await doSomethingWithProgress() + overall.addChild(p1, withPendingUnitCount: 5) + + let _ = await Task.detached { + p1.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p1.completedUnitCount = 2 + }.value + + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedUnitCount == 5) + + let p2 = Progress(totalUnitCount: 18) + overall.addChild(p2, withPendingUnitCount: 5) + + let p3 = await doSomethingWithProgress() + p2.addChild(p3, withPendingUnitCount: 9) + + let _ = await Task.detached { + p3.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p3.completedUnitCount = 2 + }.value + + await doSomething(subprogress: p2.makeChild(withPendingUnitCount: 9)) + + // Check if ProgressManager values propagate to Progress parent + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedUnitCount == 10) + } + + // MARK: Progress - ProgressReporter Interop + @Test func interopProgressParentProgressReporterChild() async throws { + // Initialize a Progress parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let p1 = await doSomethingWithProgress() + overall.addChild(p1, withPendingUnitCount: 5) + + let _ = await Task.detached { + p1.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p1.completedUnitCount = 2 + }.value + + // Check if ProgressManager values propagate to Progress parent + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedUnitCount == 5) + + // Add ProgressReporter as Child + let p2 = ProgressManager(totalCount: 10) + let p2Reporter = p2.reporter + overall.addChild(p2Reporter, withPendingUnitCount: 5) + + p2.complete(count: 10) + + // Check if Progress values propagate to Progress parent + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedUnitCount == 10) + } + + @Test func interopProgressParentProgressReporterChildWithNonZeroFractionCompleted() async throws { + // Initialize a Progress parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let p1 = await doSomethingWithProgress() + overall.addChild(p1, withPendingUnitCount: 5) + + let _ = await Task.detached { + p1.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p1.completedUnitCount = 2 + }.value + + // Check if ProgressManager values propagate to Progress parent + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedUnitCount == 5) + + // Add ProgressReporter with CompletedCount 3 as Child + let p2 = ProgressManager(totalCount: 10) + p2.complete(count: 3) + let p2Reporter = p2.reporter + overall.addChild(p2Reporter, withPendingUnitCount: 5) + + p2.complete(count: 7) + + // Check if Progress values propagate to Progress parent + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedUnitCount == 10) + } + + @Test func interopProgressParentProgressReporterGrandchild() async throws { + // Initialize a Progress parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let p1 = await doSomethingWithProgress() + overall.addChild(p1, withPendingUnitCount: 5) + + let _ = await Task.detached { + p1.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p1.completedUnitCount = 2 + }.value + + // Check if ProgressManager values propagate to Progress parent + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedUnitCount == 5) + + let p2 = await doSomethingWithProgress() + overall.addChild(p2, withPendingUnitCount: 5) + + p2.completedUnitCount = 1 + + #expect(overall.fractionCompleted == 0.75) + #expect(overall.completedUnitCount == 5) + + // Add ProgressReporter as Child + let p3 = ProgressManager(totalCount: 10) + let p3Reporter = p3.reporter + p2.addChild(p3Reporter, withPendingUnitCount: 1) + + p3.complete(count: 10) + + // Check if Progress values propagate to Progress parent + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedUnitCount == 10) + } + + // MARK: ProgressManager - Progress Interop + @Test func interopProgressManagerParentProgressChild() async throws { + // Initialize ProgressManager parent + let overallManager = ProgressManager(totalCount: 10) + + // Add ProgressManager as Child + await doSomething(subprogress: overallManager.subprogress(assigningCount: 5)) + + // Check if ProgressManager values propagate to ProgressManager parent + #expect(overallManager.fractionCompleted == 0.5) + #expect(overallManager.completedCount == 5) + + // Interop: Add Progress as Child + let p2 = await doSomethingWithProgress() + overallManager.subprogress(assigningCount: 5, to: p2) + + let _ = await Task.detached { + p2.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p2.completedUnitCount = 2 + }.value + + // Check if Progress values propagate to ProgressRerpoter parent + #expect(overallManager.completedCount == 10) + #expect(overallManager.totalCount == 10) + #expect(overallManager.fractionCompleted == 1.0) + } + + @Test func interopProgressManagerParentProgressGrandchild() async throws { + // Initialize ProgressManager parent + let overallManager = ProgressManager(totalCount: 10) + + // Add ProgressManager as Child + await doSomething(subprogress: overallManager.subprogress(assigningCount: 5)) + + #expect(overallManager.fractionCompleted == 0.5) + #expect(overallManager.completedCount == 5) + + let p2 = overallManager.subprogress(assigningCount: 5).start(totalCount: 3) + p2.complete(count: 1) + + + let p3 = await doSomethingWithProgress() + p2.subprogress(assigningCount: 2, to: p3) + + let _ = await Task.detached { + p3.completedUnitCount = 1 + try? await Task.sleep(nanoseconds: 10000) + p3.completedUnitCount = 2 + }.value + + // Check if Progress values propagate to ProgressRerpoter parent + #expect(overallManager.completedCount == 10) + #expect(overallManager.fractionCompleted == 1.0) + } + + func getProgressWithTotalCountInitialized() -> Progress { + return Progress(totalUnitCount: 5) + } + + func receiveProgress(progress: consuming Subprogress) { + let _ = progress.start(totalCount: 5) + } + + // MARK: Behavior Consistency Tests + @Test func interopProgressManagerParentProgressChildConsistency() async throws { + let overallReporter = ProgressManager(totalCount: nil) + let child = overallReporter.subprogress(assigningCount: 5) + receiveProgress(progress: child) + #expect(overallReporter.totalCount == nil) + + let overallReporter2 = ProgressManager(totalCount: nil) + let interopChild = getProgressWithTotalCountInitialized() + overallReporter2.subprogress(assigningCount: 5, to: interopChild) + #expect(overallReporter2.totalCount == nil) + } + + @Test func interopProgressParentProgressManagerChildConsistency() async throws { + let overallProgress = Progress() + let child = Progress(totalUnitCount: 5) + overallProgress.addChild(child, withPendingUnitCount: 5) + #expect(overallProgress.totalUnitCount == 0) + + let overallProgress2 = Progress() + let interopChild = overallProgress2.makeChild(withPendingUnitCount: 5) + receiveProgress(progress: interopChild) + #expect(overallProgress2.totalUnitCount == 0) + } + + #if FOUNDATION_EXIT_TESTS + @Test func indirectParticipationOfProgressInAcyclicGraph() async throws { + await #expect(processExitsWith: .failure) { + let manager = ProgressManager(totalCount: 2) + + let parentManager1 = ProgressManager(totalCount: 1) + parentManager1.assign(count: 1, to: manager.reporter) + + let parentManager2 = ProgressManager(totalCount: 1) + parentManager2.assign(count: 1, to: manager.reporter) + + let progress = Progress.discreteProgress(totalUnitCount: 4) + manager.subprogress(assigningCount: 1, to: progress) + + progress.completedUnitCount = 2 + #expect(progress.fractionCompleted == 0.5) + #expect(manager.fractionCompleted == 0.25) + #expect(parentManager1.fractionCompleted == 0.25) + #expect(parentManager2.fractionCompleted == 0.25) + + progress.addChild(parentManager1.reporter, withPendingUnitCount: 1) + } + } + #endif +} +#endif diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerPropertiesTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerPropertiesTests.swift new file mode 100644 index 000000000..889893da4 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerPropertiesTests.swift @@ -0,0 +1,1040 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import Testing + +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#else +@testable import FoundationEssentials +#endif // FOUNDATION_FRAMEWORK + +/// Unit tests for propagation of type-safe metadata in ProgressManager tree. +@Suite("Progress Manager File Properties", .tags(.progressManager)) struct ProgressManagerAdditionalPropertiesTests { + func doFileOperation(reportTo subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 100) + manager.withProperties { properties in + properties.totalFileCount = 100 + } + + #expect(manager.withProperties(\.totalFileCount) == 100) + + manager.complete(count: 100) + #expect(manager.fractionCompleted == 1.0) + #expect(manager.isFinished == true) + + manager.withProperties { properties in + properties.completedFileCount = 100 + } + #expect(manager.withProperties(\.completedFileCount) == 100) + #expect(manager.withProperties(\.totalFileCount) == 100) + } + + @Test func discreteReporterWithFileProperties() async throws { + let fileProgressManager = ProgressManager(totalCount: 3) + await doFileOperation(reportTo: fileProgressManager.subprogress(assigningCount: 3)) + #expect(fileProgressManager.fractionCompleted == 1.0) + #expect(fileProgressManager.completedCount == 3) + #expect(fileProgressManager.isFinished == true) + #expect(fileProgressManager.withProperties(\.totalFileCount) == 0) + #expect(fileProgressManager.withProperties(\.completedFileCount) == 0) + + let summaryTotalFile = fileProgressManager.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFile == 100) + + let summaryCompletedFile = fileProgressManager.summary(of: ProgressManager.Properties.CompletedFileCount.self) + #expect(summaryCompletedFile == 100) + } + + @Test func twoLevelTreeWithOneChildWithFileProperties() async throws { + let overall = ProgressManager(totalCount: 2) + + let progress1 = overall.subprogress(assigningCount: 1) + let manager1 = progress1.start(totalCount: 10) + manager1.withProperties { properties in + properties.totalFileCount = 10 + properties.completedFileCount = 0 + } + manager1.complete(count: 10) + + #expect(overall.fractionCompleted == 0.5) + + #expect(overall.withProperties(\.totalFileCount) == 0) + #expect(manager1.withProperties(\.totalFileCount) == 10) + #expect(manager1.withProperties(\.completedFileCount) == 0) + + let summaryTotalFile = overall.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFile == 10) + + let summaryCompletedFile = overall.summary(of: ProgressManager.Properties.CompletedFileCount.self) + #expect(summaryCompletedFile == 0) + } + + @Test func twoLevelTreeWithTwoChildrenWithFileProperties() async throws { + let overall = ProgressManager(totalCount: 2) + + let progress1 = overall.subprogress(assigningCount: 1) + let manager1 = progress1.start(totalCount: 10) + + manager1.withProperties { properties in + properties.totalFileCount = 11 + properties.completedFileCount = 0 + } + + let progress2 = overall.subprogress(assigningCount: 1) + let manager2 = progress2.start(totalCount: 10) + + manager2.withProperties { properties in + properties.totalFileCount = 9 + properties.completedFileCount = 0 + } + + #expect(overall.fractionCompleted == 0.0) + #expect(overall.withProperties(\.totalFileCount) == 0) + #expect(overall.withProperties(\.completedFileCount) == 0) + + let summaryTotalFile = overall.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFile == 20) + + let summaryCompletedFile = overall.summary(of: ProgressManager.Properties.CompletedFileCount.self) + #expect(summaryCompletedFile == 0) + + // Update FileCounts + manager1.withProperties { properties in + properties.completedFileCount = 1 + } + + manager2.withProperties { properties in + properties.completedFileCount = 1 + } + + #expect(overall.withProperties(\.completedFileCount) == 0) + let summaryCompletedFileUpdated = overall.summary(of: ProgressManager.Properties.CompletedFileCount.self) + #expect(summaryCompletedFileUpdated == 2) + } + + @Test func threeLevelTreeWithFileProperties() async throws { + let overall = ProgressManager(totalCount: 1) + + let progress1 = overall.subprogress(assigningCount: 1) + let manager1 = progress1.start(totalCount: 5) + + + let childProgress1 = manager1.subprogress(assigningCount: 3) + let childManager1 = childProgress1.start(totalCount: nil) + childManager1.withProperties { properties in + properties.totalFileCount += 10 + } + #expect(childManager1.withProperties(\.totalFileCount) == 10) + + let summaryTotalFileInitial = overall.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFileInitial == 10) + + let childProgress2 = manager1.subprogress(assigningCount: 2) + let childManager2 = childProgress2.start(totalCount: nil) + childManager2.withProperties { properties in + properties.totalFileCount += 10 + } + #expect(childManager2.withProperties(\.totalFileCount) == 10) + + // Tests that totalFileCount propagates to root level + #expect(overall.withProperties(\.totalFileCount) == 0) + let summaryTotalFile = overall.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFile == 20) + + manager1.withProperties { properties in + properties.totalFileCount += 999 + } + let summaryTotalFileUpdated = overall.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFileUpdated == 1019) + } +} + +@Suite("Progress Manager Byte Properties", .tags(.progressManager)) struct ProgressManagerBytePropertiesTests { + + func doSomething(subprogress: consuming Subprogress) async throws { + let manager = subprogress.start(totalCount: 3) + manager.withProperties { properties in + properties.totalByteCount = 300000 + + properties.completedCount += 1 + properties.completedByteCount += 100000 + + properties.completedCount += 1 + properties.completedByteCount += 100000 + + properties.completedCount += 1 + properties.completedByteCount += 100000 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalByteCount.self) == 300000) + #expect(manager.summary(of: ProgressManager.Properties.CompletedByteCount.self) == 300000) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async throws { + let manager = subprogress.start(totalCount: 2) + + manager.complete(count: 1) + manager.withProperties { properties in + properties.totalByteCount = 200000 + properties.completedByteCount = 200000 + } + + try await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalByteCount.self) == 500000) + #expect(manager.summary(of: ProgressManager.Properties.CompletedByteCount.self) == 500000) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.complete(count: 1) + manager.withProperties { properties in + properties.totalByteCount = 2000 + properties.completedByteCount = 1000 + } + + #expect(manager.fractionCompleted == 0.5) + #expect(manager.summary(of: ProgressManager.Properties.TotalByteCount.self) == 2000) + #expect(manager.summary(of: ProgressManager.Properties.CompletedByteCount.self) == 1000) + } + + @Test func twoLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + + try await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + manager.complete(count: 1) + manager.withProperties { properties in + properties.totalByteCount = 500000 + properties.completedByteCount = 499999 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalByteCount.self) == 800000) + #expect(manager.summary(of: ProgressManager.Properties.CompletedByteCount.self) == 799999) + } + + @Test func threeLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.complete(count: 1) + manager.withProperties { properties in + properties.totalByteCount = 100000 + properties.completedByteCount = 99999 + } + + try await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalByteCount.self) == 600000) + #expect(manager.summary(of: ProgressManager.Properties.CompletedByteCount.self) == 599999) + } +} + +@Suite("Progress Manager Throughput Properties", .tags(.progressManager)) struct ProgressManagerThroughputTests { + + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + manager.withProperties { properties in + properties.completedCount = 1 + properties.throughput += 1000 + + properties.completedCount += 1 + properties.throughput += 1000 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Throughput.self) == [2000]) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.throughput = 1000 + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Throughput.self) == [1000, 2000]) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.complete(count: 1) + manager.withProperties { properties in + properties.throughput = 1000 + properties.throughput += 2000 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Throughput.self) == [3000]) + } + + @Test func twoLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + manager.complete(count: 1) + manager.withProperties { properties in + properties.throughput = 1000 + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Throughput.self) == [1000, 2000]) + } + + @Test func threeLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + manager.complete(count: 1) + + manager.withProperties { properties in + properties.throughput = 1000 + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Throughput.self) == [1000, 1000, 2000]) + } +} + +@Suite("Progress Manager Estimated Time Remaining Properties", .tags(.progressManager)) struct ProgressManagerEstimatedTimeRemainingTests { + + func doSomething(subprogress: consuming Subprogress) async throws { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.estimatedTimeRemaining = Duration.seconds(3000) + + properties.completedCount += 1 + properties.estimatedTimeRemaining += Duration.seconds(3000) + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self) == Duration.seconds(6000)) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.estimatedTimeRemaining = Duration.seconds(1000) + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self) == Duration.seconds(1000)) + } + + @Test func twoLevelManagerWithFinishedChild() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.estimatedTimeRemaining = Duration.seconds(1) + } + + try await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self) == Duration.seconds(1)) + } + + @Test func twoLevelManagerWithUnfinishedChild() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.estimatedTimeRemaining = Duration.seconds(200) + } + + var child: ProgressManager? = manager.subprogress(assigningCount: 1).start(totalCount: 2) + child?.withProperties { properties in + properties.completedCount = 1 + properties.estimatedTimeRemaining = Duration.seconds(80000) + } + + #expect(manager.fractionCompleted == 0.75) + #expect(manager.summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self) == Duration.seconds(80000)) + + child = nil + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.EstimatedTimeRemaining.self) == Duration.seconds(200)) + } + +} + +@Suite("Progress Manager File URL Properties", .tags(.progressManager)) struct ProgressManagerFileURLTests { + + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.fileURL = URL(string: "https://www.kittens.com") + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileURL.self) == [URL(string: "https://www.kittens.com")]) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.fileURL = URL(string: "https://www.cats.com") + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileURL.self) == [URL(string: "https://www.cats.com")]) + } + + @Test func twoLevelManagerWithFinishedChild() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.fileURL = URL(string: "https://www.cats.com") + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileURL.self) == [URL(string: "https://www.cats.com")]) + } + + @Test func twoLevelManagerWithUnfinishedChild() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.fileURL = URL(string: "https://www.cats.com") + } + + var childManager: ProgressManager? = manager.subprogress(assigningCount: 1).start(totalCount: 2) + + childManager?.withProperties { properties in + properties.completedCount = 1 + properties.fileURL = URL(string: "https://www.kittens.com") + } + + #expect(manager.fractionCompleted == 0.75) + #expect(manager.summary(of: ProgressManager.Properties.FileURL.self) == [URL(string: "https://www.cats.com"), URL(string: "https://www.kittens.com")]) + + childManager = nil + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileURL.self) == [URL(string: "https://www.cats.com")]) + } +} + +extension ProgressManager.Properties { + + var counter: Counter.Type { Counter.self } + struct Counter: Sendable, ProgressManager.Property { + + typealias Value = Int + + typealias Summary = Int + + static var key: String { return "Counter" } + + static var defaultValue: Int { return 0 } + + static var defaultSummary: Int { return 0 } + + static func reduce(into summary: inout Int, value: Int) { + summary += value + } + + static func merge(_ summary1: Int, _ summary2: Int) -> Int { + return summary1 + summary2 + } + + static func terminate(_ parentSummary: Int, _ childSummary: Int) -> Int { + return parentSummary + childSummary + } + } +} + +@Suite("Progress Manager Int Properties", .tags(.progressManager)) struct ProgressManagerIntPropertiesTests { + + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 3) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.counter += 10 + + properties.completedCount += 1 + properties.counter += 10 + + properties.completedCount += 1 + properties.counter += 10 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Counter.self) == 30) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.complete(count: 1) + + manager.withProperties { properties in + properties.counter = 15 + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Counter.self) == 45) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.counter += 10 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Counter.self) == 10) + } + + @Test func twoLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.counter += 10 + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Counter.self) == 40) + } + + @Test func threeLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.counter += 10 + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.Counter.self) == 55) + } +} + +extension ProgressManager.Properties { + + var justADouble: JustADouble.Type { JustADouble.self } + struct JustADouble: Sendable, ProgressManager.Property { + + typealias Value = Double + + typealias Summary = Double + + static var key: String { return "JustADouble" } + + static var defaultValue: Double { return 0.0 } + + static var defaultSummary: Double { return 0.0 } + + static func reduce(into summary: inout Double, value: Double) { + summary += value + } + + static func merge(_ summary1: Double, _ summary2: Double) -> Double { + return summary1 + summary2 + } + + static func terminate(_ parentSummary: Double, _ childSummary: Double) -> Double { + return parentSummary + childSummary + } + } +} + +@Suite("Progress Manager Double Properties", .tags(.progressManager)) struct ProgressManagerDoublePropertiesTests { + + func doSomething(subprogress: consuming Subprogress) async throws { + let manager = subprogress.start(totalCount: 3) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.justADouble += 10.0 + + properties.completedCount += 1 + properties.justADouble += 10.0 + + properties.completedCount += 1 + properties.justADouble += 10.0 + } + + #expect(manager.summary(of: ProgressManager.Properties.JustADouble.self) == 30.0) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async throws { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.justADouble = 7.0 + } + + try await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.summary(of: ProgressManager.Properties.JustADouble.self) == 37.0) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.justADouble = 80.0 + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.JustADouble.self) == 80.0) + } + + @Test func twoLevelManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.justADouble = 80.0 + } + + try await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.JustADouble.self) == 110.0) + } + + @Test func threeLevelManager() async throws { + + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.justADouble = 80.0 + } + + try await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.JustADouble.self) == 117.0) + } +} + +extension ProgressManager.Properties { + + var downloadedFile: DownloadedFile.Type { DownloadedFile.self } + struct DownloadedFile: Sendable, ProgressManager.Property { + + typealias Value = String? + + typealias Summary = [String?] + + static var key: String { return "FileName" } + + static var defaultValue: String? { return "" } + + static var defaultSummary: [String?] { return [] } + + static func reduce(into summary: inout [String?], value: String?) { + summary.append(value) + } + + static func merge(_ summary1: [String?], _ summary2: [String?]) -> [String?] { + return summary1 + summary2 + } + + static func terminate(_ parentSummary: [String?], _ childSummary: [String?]) -> [String?] { + return parentSummary + childSummary + } + } +} + + +@Suite("Progress Manager String (Retaining) Properties", .tags(.progressManager)) struct ProgressManagerStringPropertiesTests { + + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.downloadedFile = "Melon.jpg" + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.DownloadedFile.self) == ["Melon.jpg"]) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.downloadedFile = "Cherry.jpg" + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.DownloadedFile.self) == ["Cherry.jpg", "Melon.jpg"]) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.downloadedFile = "Grape.jpg" + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.withProperties { $0.downloadedFile } == "Grape.jpg") + #expect(manager.summary(of: ProgressManager.Properties.DownloadedFile.self) == ["Grape.jpg"]) + } + + @Test func twoLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.downloadedFile = "Watermelon.jpg" + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.DownloadedFile.self) == ["Watermelon.jpg", "Melon.jpg"]) + } + + @Test func threeLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.downloadedFile = "Watermelon.jpg" + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.DownloadedFile.self) == ["Watermelon.jpg", "Cherry.jpg", "Melon.jpg"]) + } +} + +extension ProgressManager.Properties { + + var processingFile: ProcessingFile.Type { ProcessingFile.self } + struct ProcessingFile: Sendable, ProgressManager.Property { + + typealias Value = String? + + typealias Summary = [String?] + + static var key: String { return "ProcessingFile" } + + static var defaultValue: String? { return "" } + + static var defaultSummary: [String?] { return [] } + + static func reduce(into summary: inout [String?], value: String?) { + summary.append(value) + } + + static func merge(_ summary1: [String?], _ summary2: [String?]) -> [String?] { + return summary1 + summary2 + } + + static func terminate(_ parentSummary: [String?], _ childSummary: [String?]) -> [String?] { + return parentSummary + } + } +} + +@Suite("Progress Manager String (Non-retaining) Properties", .tags(.progressManager)) struct ProgressManagerStringNonRetainingProperties { + + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.processingFile = "Hello.jpg" + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.ProcessingFile.self) == ["Hello.jpg"]) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.processingFile = "Hi.jpg" + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.ProcessingFile.self) == ["Hi.jpg"]) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.processingFile = "Howdy.jpg" + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.withProperties { $0.processingFile } == "Howdy.jpg") + #expect(manager.summary(of: ProgressManager.Properties.ProcessingFile.self) == ["Howdy.jpg"]) + } + + @Test func twoLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.processingFile = "Howdy.jpg" + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.ProcessingFile.self) == ["Howdy.jpg"]) + } + + @Test func threeLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.processingFile = "Howdy.jpg" + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.ProcessingFile.self) == ["Howdy.jpg"]) + } +} + + +extension ProgressManager.Properties { + var imageURL: ImageURL.Type { ImageURL.self } + struct ImageURL: Sendable, ProgressManager.Property { + + typealias Value = URL? + + typealias Summary = [URL?] + + static var key: String { "MyApp.ImageURL" } + + static var defaultValue: URL? { nil } + + static var defaultSummary: [URL?] { [] } + + static func reduce(into summary: inout [URL?], value: URL?) { + summary.append(value) + } + + static func merge(_ summary1: [URL?], _ summary2: [URL?]) -> [URL?] { + summary1 + summary2 + } + + static func terminate(_ parentSummary: [URL?], _ childSummary: [URL?]) -> [URL?] { + parentSummary + } + } +} + +@Suite("Progress Manager URL (Non-retaining) Properties", .tags(.progressManager)) struct ProgressManagerURLProperties { + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.imageURL = URL(string: "112.jpg") + } + + #expect(manager.summary(of: ProgressManager.Properties.ImageURL.self) == [URL(string: "112.jpg")]) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.imageURL = URL(string: "114.jpg") + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.summary(of: ProgressManager.Properties.ImageURL.self) == [URL(string: "114.jpg")]) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.imageURL = URL(string: "116.jpg") + } + + #expect(manager.fractionCompleted == 0.0) + #expect(manager.summary(of: ProgressManager.Properties.ImageURL.self) == [URL(string: "116.jpg")]) + } + + @Test func twoLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.imageURL = URL(string: "116.jpg") + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.ImageURL.self) == [URL(string: "116.jpg")]) + } + + @Test func threeLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.imageURL = URL(string: "116.jpg") + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.ImageURL.self) == [URL(string: "116.jpg")]) + } +} + +extension ProgressManager.Properties { + var totalPixelCount: TotalPixelCount.Type { TotalPixelCount.self } + struct TotalPixelCount: Sendable, ProgressManager.Property { + typealias Value = UInt64 + + typealias Summary = [UInt64] + + static var key: String { "MyApp.TotalPixelCount" } + + static var defaultValue: UInt64 { 0 } + + static var defaultSummary: [UInt64] { [] } + + static func reduce(into summary: inout [UInt64], value: UInt64) { + summary.append(value) + } + + static func merge(_ summary1: [UInt64], _ summary2: [UInt64]) -> [UInt64] { + summary1 + summary2 + } + + static func terminate(_ parentSummary: [UInt64], _ childSummary: [UInt64]) -> [UInt64] { + parentSummary + childSummary + } + } +} + +@Suite("Progress Manager UInt64 (Retaining) Properties", .tags(.progressManager)) struct ProgressManagerUInt64Properties { + + func doSomething(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.totalPixelCount = 24 + } + + #expect(manager.summary(of: ProgressManager.Properties.TotalPixelCount.self) == [24]) + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.totalPixelCount = 26 + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.summary(of: ProgressManager.Properties.TotalPixelCount.self) == [26, 24]) + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.totalPixelCount = 42 + } + + #expect(manager.fractionCompleted == 0.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalPixelCount.self) == [42]) + } + + @Test func twoLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.totalPixelCount = 42 + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalPixelCount.self) == [42, 24]) + } + + @Test func threeLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.totalPixelCount = 42 + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.TotalPixelCount.self) == [42, 26, 24]) + } +} diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift new file mode 100644 index 000000000..6f8b8fd49 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift @@ -0,0 +1,410 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import Testing + +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#else +@testable import FoundationEssentials +#endif // FOUNDATION_FRAMEWORK + +extension Tag { + @Tag static var progressManager: Self +} + +/// Unit tests for basic functionalities of ProgressManager +@Suite("Progress Manager", .tags(.progressManager)) struct ProgressManagerTests { + /// MARK: Helper methods that report progress + func doBasicOperationV1(reportTo subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 8) + for i in 1...8 { + manager.complete(count: 1) + #expect(manager.completedCount == i) + #expect(manager.fractionCompleted == Double(i) / Double(8)) + } + } + + func doBasicOperationV2(reportTo subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 7) + for i in 1...7 { + manager.complete(count: 1) + #expect(manager.completedCount == i) + #expect(manager.fractionCompleted == Double(i) / Double(7)) + } + } + + func doBasicOperationV3(reportTo subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 11) + for i in 1...11 { + manager.complete(count: 1) + #expect(manager.completedCount == i) + #expect(manager.fractionCompleted == Double(i) / Double(11)) + } + } + + /// MARK: Tests calculations based on change in totalCount + @Test func totalCountNil() async throws { + let overall = ProgressManager(totalCount: nil) + overall.complete(count: 10) + #expect(overall.completedCount == 10) + #expect(overall.fractionCompleted == 0.0) + #expect(overall.isIndeterminate == true) + #expect(overall.totalCount == nil) + } + + @Test func totalCountReset() async throws { + let overall = ProgressManager(totalCount: 10) + overall.complete(count: 5) + #expect(overall.completedCount == 5) + #expect(overall.totalCount == 10) + #expect(overall.fractionCompleted == 0.5) + #expect(overall.isIndeterminate == false) + + overall.withProperties { p in + p.totalCount = nil + p.completedCount += 1 + } + #expect(overall.completedCount == 6) + #expect(overall.totalCount == nil) + #expect(overall.fractionCompleted == 0.0) + #expect(overall.isIndeterminate == true) + #expect(overall.isFinished == false) + + overall.withProperties { p in + p.totalCount = 12 + p.completedCount += 2 + } + #expect(overall.completedCount == 8) + #expect(overall.totalCount == 12) + #expect(overall.fractionCompleted == Double(8) / Double(12)) + #expect(overall.isIndeterminate == false) + #expect(overall.isFinished == false) + } + + @Test func totalCountNilWithChild() async throws { + let overall = ProgressManager(totalCount: nil) + #expect(overall.completedCount == 0) + #expect(overall.totalCount == nil) + #expect(overall.fractionCompleted == 0.0) + #expect(overall.isIndeterminate == true) + #expect(overall.isFinished == false) + + let progress1 = overall.subprogress(assigningCount: 2) + let manager1 = progress1.start(totalCount: 1) + + manager1.complete(count: 1) + #expect(manager1.totalCount == 1) + #expect(manager1.completedCount == 1) + #expect(manager1.fractionCompleted == 1.0) + #expect(manager1.isIndeterminate == false) + #expect(manager1.isFinished == true) + + #expect(overall.completedCount == 2) + #expect(overall.totalCount == nil) + #expect(overall.fractionCompleted == 0.0) + #expect(overall.isIndeterminate == true) + #expect(overall.isFinished == false) + + overall.withProperties { p in + p.totalCount = 5 + } + #expect(overall.completedCount == 2) + #expect(overall.totalCount == 5) + #expect(overall.fractionCompleted == 0.4) + #expect(overall.isIndeterminate == false) + #expect(overall.isFinished == false) + } + + @Test func totalCountFinishesWithLessCompletedCount() async throws { + let overall = ProgressManager(totalCount: 10) + overall.complete(count: 5) + + let progress1 = overall.subprogress(assigningCount: 8) + let manager1 = progress1.start(totalCount: 1) + manager1.complete(count: 1) + + #expect(overall.completedCount == 13) + #expect(overall.totalCount == 10) + #expect(overall.fractionCompleted == 1.3) + #expect(overall.isIndeterminate == false) + #expect(overall.isFinished == true) + } + + @Test func childTotalCountReset() async throws { + let overall = ProgressManager(totalCount: 1) + + let childManager = overall.subprogress(assigningCount: 1).start(totalCount: 4) + childManager.complete(count: 2) + + #expect(overall.fractionCompleted == 0.5) + #expect(childManager.isIndeterminate == false) + + childManager.withProperties { properties in + properties.totalCount = nil + } + + #expect(overall.fractionCompleted == 0.0) + #expect(childManager.isIndeterminate == true) + #expect(childManager.completedCount == 2) + + childManager.withProperties { properties in + properties.totalCount = 5 + } + childManager.complete(count: 2) + + #expect(overall.fractionCompleted == 0.8) + #expect(childManager.completedCount == 4) + #expect(childManager.isIndeterminate == false) + + childManager.complete(count: 1) + #expect(overall.fractionCompleted == 1.0) + } + + /// MARK: Tests single-level tree + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 3) + await doBasicOperationV1(reportTo: manager.subprogress(assigningCount: 3)) + #expect(manager.fractionCompleted == 1.0) + #expect(manager.completedCount == 3) + #expect(manager.isFinished == true) + } + + /// MARK: Tests multiple-level trees + @Test func emptyDiscreteManager() async throws { + let manager = ProgressManager(totalCount: nil) + #expect(manager.isIndeterminate == true) + + manager.withProperties { p in + p.totalCount = 10 + } + #expect(manager.isIndeterminate == false) + #expect(manager.totalCount == 10) + + await doBasicOperationV1(reportTo: manager.subprogress(assigningCount: 10)) + #expect(manager.fractionCompleted == 1.0) + #expect(manager.completedCount == 10) + #expect(manager.isFinished == true) + } + + @Test func twoLevelTreeWithTwoChildren() async throws { + let overall = ProgressManager(totalCount: 2) + + await doBasicOperationV1(reportTo: overall.subprogress(assigningCount: 1)) + #expect(overall.fractionCompleted == 0.5) + #expect(overall.completedCount == 1) + #expect(overall.isFinished == false) + #expect(overall.isIndeterminate == false) + + await doBasicOperationV2(reportTo: overall.subprogress(assigningCount: 1)) + #expect(overall.fractionCompleted == 1.0) + #expect(overall.completedCount == 2) + #expect(overall.isFinished == true) + #expect(overall.isIndeterminate == false) + } + + @Test func twoLevelTreeWithTwoChildrenWithOneFileProperty() async throws { + let overall = ProgressManager(totalCount: 2) + + let progress1 = overall.subprogress(assigningCount: 1) + let manager1 = progress1.start(totalCount: 5) + manager1.complete(count: 5) + + let progress2 = overall.subprogress(assigningCount: 1) + let manager2 = progress2.start(totalCount: 5) + manager2.withProperties { properties in + properties.totalFileCount = 10 + } + + #expect(overall.fractionCompleted == 0.5) + // Parent is expected to get totalFileCount from one of the children with a totalFileCount + #expect(overall.withProperties(\.totalFileCount) == 0) + } + + @Test func twoLevelTreeWithMultipleChildren() async throws { + let overall = ProgressManager(totalCount: 3) + + await doBasicOperationV1(reportTo: overall.subprogress(assigningCount:1)) + #expect(overall.fractionCompleted == Double(1) / Double(3)) + #expect(overall.completedCount == 1) + + await doBasicOperationV2(reportTo: overall.subprogress(assigningCount:1)) + #expect(overall.fractionCompleted == Double(2) / Double(3)) + #expect(overall.completedCount == 2) + + await doBasicOperationV3(reportTo: overall.subprogress(assigningCount:1)) + #expect(overall.fractionCompleted == Double(3) / Double(3)) + #expect(overall.completedCount == 3) + } + + @Test func threeLevelTree() async throws { + let overall = ProgressManager(totalCount: 100) + #expect(overall.fractionCompleted == 0.0) + + let child1 = overall.subprogress(assigningCount: 100) + let manager1 = child1.start(totalCount: 100) + + let grandchild1 = manager1.subprogress(assigningCount: 100) + let grandchildManager1 = grandchild1.start(totalCount: 100) + + #expect(overall.fractionCompleted == 0.0) + + grandchildManager1.complete(count: 50) + #expect(manager1.fractionCompleted == 0.5) + #expect(overall.fractionCompleted == 0.5) + + grandchildManager1.complete(count: 50) + #expect(manager1.fractionCompleted == 1.0) + #expect(overall.fractionCompleted == 1.0) + + #expect(grandchildManager1.isFinished == true) + #expect(manager1.isFinished == true) + #expect(overall.isFinished == true) + } + + @Test func fourLevelTree() async throws { + let overall = ProgressManager(totalCount: 100) + #expect(overall.fractionCompleted == 0.0) + + let child1 = overall.subprogress(assigningCount: 100) + let manager1 = child1.start(totalCount: 100) + + let grandchild1 = manager1.subprogress(assigningCount: 100) + let grandchildManager1 = grandchild1.start(totalCount: 100) + + #expect(overall.fractionCompleted == 0.0) + + let greatGrandchild1 = grandchildManager1.subprogress(assigningCount: 100) + let greatGrandchildManager1 = greatGrandchild1.start(totalCount: 100) + + greatGrandchildManager1.complete(count: 50) + #expect(overall.fractionCompleted == 0.5) + + greatGrandchildManager1.complete(count: 50) + #expect(overall.fractionCompleted == 1.0) + + #expect(greatGrandchildManager1.isFinished == true) + #expect(grandchildManager1.isFinished == true) + #expect(manager1.isFinished == true) + #expect(overall.isFinished == true) + } + + func doSomething(amount: Int, subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: amount) + for _ in 1...amount { + manager.complete(count: 1) + } + } + + @Test func fiveThreadsMutatingAndReading() async throws { + let manager = ProgressManager(totalCount: 10) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await doSomething(amount: 5, subprogress: manager.subprogress(assigningCount: 1)) + } + + group.addTask { + await doSomething(amount: 8, subprogress: manager.subprogress(assigningCount: 1)) + } + + group.addTask { + await doSomething(amount: 7, subprogress: manager.subprogress(assigningCount: 1)) + } + + group.addTask { + await doSomething(amount: 6, subprogress: manager.subprogress(assigningCount: 1)) + } + + group.addTask { + #expect(manager.fractionCompleted <= 0.4) + } + } + } + + // MARK: Test deinit behavior + func makeUnfinishedChild(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 3) + manager.complete(count: 2) + #expect(manager.fractionCompleted == Double(2) / Double(3)) + } + + func makeFinishedChild(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + manager.complete(count: 2) + } + + @Test func unfinishedChild() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.complete(count: 1) + #expect(manager.fractionCompleted == 0.5) + + await makeUnfinishedChild(subprogress: manager.subprogress(assigningCount: 1)) + #expect(manager.fractionCompleted == 1.0) + } + + @Test func unfinishedGrandchild() async throws { + let manager = ProgressManager(totalCount: 1) + + let child = manager.subprogress(assigningCount: 1).start(totalCount: 1) + + await makeUnfinishedChild(subprogress: child.subprogress(assigningCount: 1)) + #expect(manager.fractionCompleted == 1.0) + } + + @Test func unfinishedGreatGrandchild() async throws { + let manager = ProgressManager(totalCount: 1) + + let child = manager.subprogress(assigningCount: 1).start(totalCount: 1) + + let grandchild = child.subprogress(assigningCount: 1).start(totalCount: 1) + + await makeUnfinishedChild(subprogress: grandchild.subprogress(assigningCount: 1)) + #expect(manager.fractionCompleted == 1.0) + } + + @Test func finishedChildUnreadBeforeDeinit() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.complete(count: 1) + #expect(manager.fractionCompleted == 0.5) + + await makeFinishedChild(subprogress: manager.subprogress(assigningCount: 1)) + #expect(manager.fractionCompleted == 1.0) + } + + @Test func finishedChildReadBeforeDeinit() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.complete(count: 1) + #expect(manager.fractionCompleted == 0.5) + + var child: ProgressManager? = manager.subprogress(assigningCount: 1).start(totalCount: 1) + child?.complete(count: 1) + #expect(manager.fractionCompleted == 1.0) + + child = nil + #expect(manager.fractionCompleted == 1.0) + } + + @Test func uninitializedSubprogress() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.complete(count: 1) + + var subprogress: Subprogress? = manager.subprogress(assigningCount: 1) + #expect(manager.fractionCompleted == 0.5) + + subprogress = nil + #expect(manager.fractionCompleted == 1.0) + } +} diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressReporterTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressReporterTests.swift new file mode 100644 index 000000000..0bcc40784 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressReporterTests.swift @@ -0,0 +1,154 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import Testing + +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#else +@testable import FoundationEssentials +#endif // FOUNDATION_FRAMEWORK + +@Suite("Progress Reporter", .tags(.progressManager)) struct ProgressReporterTests { + @Test func observeProgressReporter() { + let manager = ProgressManager(totalCount: 3) + + let reporter = manager.reporter + + manager.complete(count: 1) + #expect(reporter.completedCount == 1) + + manager.complete(count: 1) + #expect(reporter.completedCount == 2) + + manager.complete(count: 1) + #expect(reporter.completedCount == 3) + + let fileCount = reporter.withProperties { properties in + properties.totalFileCount + } + #expect(fileCount == 0) + + manager.withProperties { properties in + properties.totalFileCount = 6 + } + #expect(reporter.withProperties(\.totalFileCount) == 6) + + let summaryTotalFile = manager.summary(of: ProgressManager.Properties.TotalFileCount.self) + #expect(summaryTotalFile == 6) + } + + @Test func testAddProgressReporterAsChild() { + let manager = ProgressManager(totalCount: 2) + + let reporter = manager.reporter + + let altManager1 = ProgressManager(totalCount: 4) + altManager1.assign(count: 1, to: reporter) + + let altManager2 = ProgressManager(totalCount: 5) + altManager2.assign(count: 2, to: reporter) + + manager.complete(count: 1) + #expect(altManager1.fractionCompleted == 0.125) + #expect(altManager2.fractionCompleted == 0.2) + + manager.complete(count: 1) + #expect(altManager1.fractionCompleted == 0.25) + #expect(altManager2.fractionCompleted == 0.4) + } + + @Test func testAssignToProgressReporterThenSetTotalCount() { + let overall = ProgressManager(totalCount: nil) + + let child1 = ProgressManager(totalCount: 10) + overall.assign(count: 10, to: child1.reporter) + child1.complete(count: 5) + + let child2 = ProgressManager(totalCount: 20) + overall.assign(count: 20, to: child2.reporter) + child2.complete(count: 20) + + overall.withProperties { properties in + properties.totalCount = 30 + } + #expect(overall.completedCount == 20) + #expect(overall.fractionCompleted == Double(25) / Double(30)) + + child1.complete(count: 5) + + #expect(overall.completedCount == 30) + #expect(overall.fractionCompleted == 1.0) + } + + @Test func testMakeSubprogressThenSetTotalCount() async { + let overall = ProgressManager(totalCount: nil) + + let reporter1 = await dummy(index: 1, subprogress: overall.subprogress(assigningCount: 10)) + + let reporter2 = await dummy(index: 2, subprogress: overall.subprogress(assigningCount: 20)) + + #expect(reporter1.fractionCompleted == 0.5) + + #expect(reporter2.fractionCompleted == 0.5) + + overall.withProperties { properties in + properties.totalCount = 30 + } + + #expect(overall.totalCount == 30) + #expect(overall.fractionCompleted == 0.5) + } + + func dummy(index: Int, subprogress: consuming Subprogress) async -> ProgressReporter { + let manager = subprogress.start(totalCount: index * 10) + + manager.complete(count: (index * 10) / 2) + + return manager.reporter + } + + #if FOUNDATION_EXIT_TESTS + @Test func testProgressReporterDirectCycleDetection() async { + await #expect(processExitsWith: .failure) { + let manager = ProgressManager(totalCount: 2) + manager.assign(count: 1, to: manager.reporter) + } + } + + @Test func testProgressReporterIndirectCycleDetection() async throws { + await #expect(processExitsWith: .failure) { + let manager = ProgressManager(totalCount: 2) + + let altManager = ProgressManager(totalCount: 1) + altManager.assign(count: 1, to: manager.reporter) + + manager.assign(count: 1, to: altManager.reporter) + } + } + + @Test func testProgressReporterNestedCycleDetection() async throws { + + await #expect(processExitsWith: .failure) { + let manager1 = ProgressManager(totalCount: 1) + + let manager2 = ProgressManager(totalCount: 2) + manager1.assign(count: 1, to: manager2.reporter) + + let manager3 = ProgressManager(totalCount: 3) + manager2.assign(count: 1, to: manager3.reporter) + + manager3.assign(count: 1, to: manager1.reporter) + + } + } + #endif +}