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..b76acbc64 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Interop.swift @@ -0,0 +1,270 @@ +//===----------------------------------------------------------------------===// +// +// 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? + } +} + +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 + if let reporterBridge { + state.interopObservation.reporterBridge = reporterBridge + } + + if let nsProgressBridge { + state.interopObservation.nsProgressBridge = nsProgressBridge + } + } + } + + internal func setInteropChild(interopChild: ProgressManager) { + state.withLock { state in + state.interopChild = interopChild + } + } +} +#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..13bf70f77 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Accessors.swift @@ -0,0 +1,496 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + + /// Returns a summary for the specified 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: The aggregated summary value for the specified property across the entire subtree. + 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 the specified 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: The aggregated summary value for the specified property across the entire subtree. + 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 the specified 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 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) -> P.Summary where P.Value == String, P.Summary == String { + return getUpdatedStringSummary(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) + } + + /// 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) + } + + /// 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 { + let throughput = getUpdatedThroughput() + return throughput.values / UInt64(throughput.count) + } + + /// 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() + } + + // MARK: Additional Properties Methods + 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 + } + } + + /// 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: [:], + interopObservation: InteropObservation(subprogressBridge: nil), + observers: [] + ) +#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: [:] + ) +#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 FOUNDATION_FRAMEWORK + if let observerState = values.observerState { + if let _ = state.interopObservation.reporterBridge { + notifyObservers(with: observerState) + } + } +#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] = [] +#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 both the value + /// and summary types are `String`. If the property has not een 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)) + } + } +#if FOUNDATION_FRAMEWORK + private mutating func interopNotifications() { + state.interopObservation.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) + } +#endif + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Definitions.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Definitions.swift new file mode 100644 index 000000000..e4a4fe59d --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Definitions.swift @@ -0,0 +1,261 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 like completion counts and file counts. + /// + /// Properties use a key-value system where the key uniquely identifies the property type, + /// and values can be aggregated across progress manager hierarchies through reduction and merging operations. + public protocol Property: SendableMetatype { + + /// The type of individual values stored in this property. + /// + /// This associated type represents the data type of individual property values + /// that can be set on progress managers. Must be `Sendable` and `Equatable`. + associatedtype Value: Sendable, Equatable + + /// The type used for aggregated summaries of this property. + /// + /// This associated type represents the data type used when summarizing property values + /// across multiple progress managers in a hierarchy. Must be `Sendable` and `Equatable`. + 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, or as a fallback when summarization fails. + /// + /// - 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 + } + + // 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 + } + } + + /// 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 + } + } + + /// 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 + } + } + + /// 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 + } + } + + /// The throughput, in bytes per second. + public var throughput: Throughput.Type { Throughput.self } + public struct Throughput: Sendable, Property { + public typealias Value = UInt64 + + public struct AggregateThroughput: Sendable, Equatable { + var values: UInt64 + var count: Int + } + + public typealias Summary = AggregateThroughput + + public static var key: String { return "Foundation.ProgressManager.Properties.Throughput" } + + public static var defaultValue: UInt64 { return 0 } + + public static var defaultSummary: AggregateThroughput { return AggregateThroughput(values: 0, count: 0) } + + public static func reduce(into summary: inout AggregateThroughput, value: UInt64) { + summary = Summary(values: summary.values + value, count: summary.count + 1) + } + + public static func merge(_ summary1: AggregateThroughput, _ summary2: AggregateThroughput) -> AggregateThroughput { + return Summary(values: summary1.values + summary2.values, count: summary1.count + summary2.count) + } + } + + /// 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) + } + } + + + /// 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 + } + + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Helpers.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Helpers.swift new file mode 100644 index 000000000..7c5a79b50 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties+Helpers.swift @@ -0,0 +1,562 @@ +//===----------------------------------------------------------------------===// +// +// 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: Methods to get updated summary of properties + 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 { + 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 { + if let remainingProperties = childState.remainingPropertiesInt { + if let remainingSummary = remainingProperties[property] { + value = property.merge(value, remainingSummary) + } + } + } + } else { + value = property.merge(value, childPropertyState.value) + } + } else { + if let child = childState.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) + } else { + // Get value from remainingProperties + if let remainingProperties = childState.remainingPropertiesInt { + if let remainingSummary = remainingProperties[property] { + value = property.merge(value, remainingSummary) + } + } + } + } + } + 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 { + // Get value from remainingProperties + if let remainingProperties = childState.remainingPropertiesDouble { + if let remainingSummary = remainingProperties[property] { + value = property.merge(value, remainingSummary) + } + } + } + } else { + // Merge non-dirty, updated value + value = property.merge(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) + } else { + // Get value from remainingProperties + if let remainingProperties = childState.remainingPropertiesDouble { + if let remainingSummary = remainingProperties[property] { + value = property.merge(value, remainingSummary) + } + } + } + } + } + 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 { + // Get value from remainingProperties + if let remainingProperties = childState.remainingPropertiesString { + if let remainingSummary = remainingProperties[property] { + value = property.merge(value, remainingSummary) + } + } + } + } else { + // Merge non-dirty, updated value + value = property.merge(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) + } else { + // Get value from remainingProperties + if let remainingProperties = childState.remainingPropertiesString { + if let remainingSummary = remainingProperties[property] { + value = property.merge(value, remainingSummary) + } + } + } + } + } + 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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.TotalFileCount.merge(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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.CompletedFileCount.merge(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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.TotalByteCount.merge(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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.CompletedByteCount.merge(value, childState.completedByteCount.value) + } + } + return value + } + } + } + + internal func getUpdatedThroughput() -> ProgressManager.Properties.Throughput.AggregateThroughput { + return state.withLock { state in + // Get self's throughput as part of summary + var value: ProgressManager.Properties.Throughput.AggregateThroughput = 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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.Throughput.merge(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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.EstimatedTimeRemaining.merge(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 { + // Merge non-dirty, updated value + value = ProgressManager.Properties.FileURL.merge(value, childState.fileURL.value) + } + } + return value + } + } + + //MARK: Methods to set dirty bit recursively + 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: 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: Methods to preserve values of properties upon deinit + internal func setChildRemainingPropertiesInt(_ properties: [MetatypeWrapper: Int], at position: Int) { + state.withLock { state in + state.children[position].remainingPropertiesInt = properties + } + } + + internal func setChildRemainingPropertiesDouble(_ properties: [MetatypeWrapper: Double], at position: Int) { + state.withLock { state in + state.children[position].remainingPropertiesDouble = properties + } + } + + internal func setChildRemainingPropertiesString(_ properties: [MetatypeWrapper: String], at position: Int) { + state.withLock { state in + state.children[position].remainingPropertiesString = properties + } + } + + internal func setChildTotalFileCount(value: Int, at position: Int) { + state.withLock { state in + state.children[position].totalFileCount = PropertyStateInt(value: value, isDirty: false) + } + } + + internal func setChildCompletedFileCount(value: Int, at position: Int) { + state.withLock { state in + state.children[position].completedFileCount = PropertyStateInt(value: value, isDirty: false) + } + } + + internal func setChildTotalByteCount(value: UInt64, at position: Int) { + state.withLock { state in + state.children[position].totalByteCount = PropertyStateUInt64(value: value, isDirty: false) + } + } + + internal func setChildCompletedByteCount(value: UInt64, at position: Int) { + state.withLock { state in + state.children[position].completedByteCount = PropertyStateUInt64(value: value, isDirty: false) + } + } + + internal func setChildThroughput(value: ProgressManager.Properties.Throughput.AggregateThroughput, at position: Int) { + state.withLock { state in + state.children[position].throughput = PropertyStateThroughput(value: value, isDirty: false) + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift new file mode 100644 index 000000000..2bd9a581a --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+State.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// +// 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 T, T) -> () + let merge: @Sendable (T, T) -> T + + let defaultValue: T + let defaultSummary: T + + let key: String + + init(_ argument: P.Type) where P.Value == T, P.Summary == T { + reduce = P.reduce + merge = P.merge + 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: ProgressManager.Properties.Throughput.AggregateThroughput + 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 remainingPropertiesInt: [MetatypeWrapper: Int]? + var remainingPropertiesDouble: [MetatypeWrapper: Double]? + var remainingPropertiesString: [MetatypeWrapper: String]? + 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] + } + + internal struct ParentState { + var parent: ProgressManager + var positionInParent: Int + } + + internal struct State { + var selfFraction: ProgressFraction + var overallFraction: ProgressFraction { + var overallFraction = selfFraction + for child in children { + if !child.childFraction.isFinished { + let multiplier = ProgressFraction(completed: child.portionOfTotal, total: selfFraction.total) + if let additionalFraction = multiplier * child.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] +#if FOUNDATION_FRAMEWORK + var interopChild: ProgressManager? + var interopObservation: InteropObservation + var observers: [@Sendable (ObserverState) -> Void] +#endif + + /// Returns nil if `self` was instantiated without total units; + /// returns a `Int` value otherwise. + internal func getTotalCount() -> Int? { +#if FOUNDATION_FRAMEWORK + if let interopChild = interopChild { + return interopChild.totalCount + } +#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 interopChild = interopChild { + return interopChild.completedCount + } +#endif + + updateChildrenProgressFraction() + + return selfFraction.completed + } + + 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 + } + } + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift new file mode 100644 index 000000000..9cd23bedb --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift @@ -0,0 +1,413 @@ +//===----------------------------------------------------------------------===// +// +// 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 +#if FOUNDATION_FRAMEWORK + if let interopChild = state.interopChild { + return interopChild.fractionCompleted + } +#endif + + state.updateChildrenProgressFraction() + + return state.overallFraction.fractionCompleted + } + } + + /// 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 +#if FOUNDATION_FRAMEWORK + if let interopChild = state.interopChild { + return interopChild.isIndeterminate + } +#endif + return state.selfFraction.isIndeterminate + } + } + + /// 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 +#if FOUNDATION_FRAMEWORK + if let interopChild = state.interopChild { + return interopChild.isIndeterminate + } +#endif + return state.selfFraction.isFinished + } + } + + /// 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: [:], + interopChild: nil, + interopObservation: InteropObservation(subprogressBridge: subprogressBridge), + observers: [] + ) + 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: [:] + ) + 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.selfFraction.completed += count + +#if FOUNDATION_FRAMEWORK + state.interopObservation.subprogressBridge?.manager.notifyObservers( + with: .fractionUpdated( + totalCount: state.selfFraction.total ?? 0, + completedCount: state.selfFraction.completed + ) + ) + + if let _ = state.interopObservation.reporterBridge { + state.notifyObservers( + with: .fractionUpdated( + totalCount: state.selfFraction.total ?? 0, + completedCount: state.selfFraction.completed + ) + ) + } +#endif + + return state.parents + } + if let parents = parents { + markSelfDirty(parents: parents) + } + } + + //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, + remainingPropertiesInt: nil, + 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: [:]) + 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 { + if !isFinished { + self.withProperties { properties in + if let totalCount = properties.totalCount { + properties.completedCount = totalCount + } + } + } + + let (propertiesInt, propertiesDouble, propertiesString, parents) = state.withLock { state in + return (state.propertiesInt, state.propertiesDouble, state.propertiesString, 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 + } + + + for parentState in parents { + parentState.parent.setChildRemainingPropertiesInt(finalSummaryInt, at: parentState.positionInParent) + parentState.parent.setChildRemainingPropertiesDouble(finalSummaryDouble, at: parentState.positionInParent) + parentState.parent.setChildRemainingPropertiesString(finalSummaryString, at: parentState.positionInParent) + parentState.parent.setChildTotalFileCount(value: self.getUpdatedFileCount(type: .total), at: parentState.positionInParent) + parentState.parent.setChildCompletedFileCount(value: self.getUpdatedFileCount(type: .completed), at: parentState.positionInParent) + parentState.parent.setChildTotalByteCount(value: self.getUpdatedByteCount(type: .total), at: parentState.positionInParent) + parentState.parent.setChildCompletedByteCount(value: self.getUpdatedByteCount(type: .completed), at: parentState.positionInParent) + parentState.parent.setChildThroughput(value: self.getUpdatedThroughput(), at: parentState.positionInParent) + } + } +} + +@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..3a3195b6b --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift @@ -0,0 +1,178 @@ +//===----------------------------------------------------------------------===// +// +// 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, 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 { + 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 + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/Subprogress.swift b/Sources/FoundationEssentials/ProgressManager/Subprogress.swift new file mode 100644 index 000000000..bc4f02e79 --- /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(interopChild: 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..8992e51fe --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerPropertiesTests.swift @@ -0,0 +1,734 @@ +//===----------------------------------------------------------------------===// +// +// 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) == 1500) + } + + @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) == 1500) + } + + @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) == Int64(4000 / 3)) + } +} + +@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) + } + + let child = 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)) + } + +} + +@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") + } + + let childManager = 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")]) + } +} + +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 + } + } +} + +@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 + } + } +} + +@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 fileName: FileName.Type { FileName.self } + struct FileName: 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 += value + } + + static func merge(_ summary1: String, _ summary2: String) -> String { + return summary1 + ", " + summary2 + } + } +} + + +@Suite("Progress Manager String 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.fileName = "Melon.jpg" + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileName.self) == "Melon.jpg") + } + + func doSomethingTwoLevels(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.fileName = "Cherry.jpg" + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileName.self) == "Cherry.jpg, Melon.jpg") + } + + @Test func discreteManager() async throws { + let manager = ProgressManager(totalCount: 1) + + manager.withProperties { properties in + properties.completedCount += 1 + properties.fileName = "Grape.jpg" + } + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.withProperties { $0.fileName } == "Grape.jpg") + #expect(manager.summary(of: ProgressManager.Properties.FileName.self) == "Grape.jpg") + } + + @Test func twoLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.fileName = "Watermelon.jpg" + } + + await doSomething(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileName.self) == "Watermelon.jpg, Melon.jpg") + } + + @Test func threeLevelsManager() async throws { + let manager = ProgressManager(totalCount: 2) + + manager.withProperties { properties in + properties.completedCount = 1 + properties.fileName = "Watermelon.jpg" + } + + await doSomethingTwoLevels(subprogress: manager.subprogress(assigningCount: 1)) + + #expect(manager.fractionCompleted == 1.0) + #expect(manager.summary(of: ProgressManager.Properties.FileName.self) == "Watermelon.jpg, Cherry.jpg, Melon.jpg") + } +} diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift new file mode 100644 index 000000000..650ea9751 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift @@ -0,0 +1,348 @@ +//===----------------------------------------------------------------------===// +// +// 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) + } + } + } + + func makeUnfinishedChild(subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 3) + manager.complete(count: 2) + #expect(manager.fractionCompleted == Double(2) / Double(3)) + } + + @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) + } +} 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 +}