diff --git a/Package.swift b/Package.swift index 327c8cfbb..5e521d6cb 100644 --- a/Package.swift +++ b/Package.swift @@ -133,7 +133,8 @@ let package = Package( "CMakeLists.txt", "ProcessInfo/CMakeLists.txt", "FileManager/CMakeLists.txt", - "URL/CMakeLists.txt" + "URL/CMakeLists.txt", + "ProgressManager/CMakeLists.txt", ], cSettings: [ .define("_GNU_SOURCE", .when(platforms: [.linux])) @@ -195,7 +196,8 @@ let package = Package( "Locale/CMakeLists.txt", "Calendar/CMakeLists.txt", "CMakeLists.txt", - "Predicate/CMakeLists.txt" + "Predicate/CMakeLists.txt", + "ProgressManager/CMakeLists.txt", ], cSettings: wasiLibcCSettings, swiftSettings: [ diff --git a/Sources/FoundationEssentials/CMakeLists.txt b/Sources/FoundationEssentials/CMakeLists.txt index 79435c105..b9a493cd4 100644 --- a/Sources/FoundationEssentials/CMakeLists.txt +++ b/Sources/FoundationEssentials/CMakeLists.txt @@ -44,6 +44,7 @@ add_subdirectory(JSON) add_subdirectory(Locale) add_subdirectory(Predicate) add_subdirectory(ProcessInfo) +add_subdirectory(ProgressManager) add_subdirectory(PropertyList) add_subdirectory(String) add_subdirectory(TimeZone) diff --git a/Sources/FoundationEssentials/LockedState.swift b/Sources/FoundationEssentials/LockedState.swift index 2316fb955..5a3953dae 100644 --- a/Sources/FoundationEssentials/LockedState.swift +++ b/Sources/FoundationEssentials/LockedState.swift @@ -113,13 +113,17 @@ package struct LockedState { return initialState }) } - - package func withLock(_ body: @Sendable (inout State) throws -> T) rethrows -> T { + + package func withLock( + _ body: (inout sending State) throws(E) -> sending T + ) throws(E) -> sending T { try withLockUnchecked(body) } - - package func withLockUnchecked(_ body: (inout State) throws -> T) rethrows -> T { - try _buffer.withUnsafeMutablePointers { state, lock in + + package func withLockUnchecked( + _ body: (inout sending State) throws(E) -> sending T + ) throws(E) -> sending T { + try _buffer.withUnsafeMutablePointers { (state, lock) throws(E) in _Lock.lock(lock) defer { _Lock.unlock(lock) } return try body(&state.pointee) diff --git a/Sources/FoundationEssentials/ProgressManager/CMakeLists.txt b/Sources/FoundationEssentials/ProgressManager/CMakeLists.txt new file mode 100644 index 000000000..a3f1e920b --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/CMakeLists.txt @@ -0,0 +1,20 @@ +##===----------------------------------------------------------------------===## +## +## 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.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..6c5a9b19a --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressFraction.swift @@ -0,0 +1,282 @@ +//===----------------------------------------------------------------------===// +// +// 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 = 0 + 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 + } + (self.completed, self.total) = _ProgressFraction._fromDouble(double) + self.overflowed = overflow + } + + init(completed: Int, total: Int?) { + if let total { + self.total = total + self.completed = completed + } else { + self.total = 0 + 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() { + if self.total == 0 { + return + } + + (self.completed, self.total) = _ProgressFraction._simplify(completed, total) + } + + internal func simplified() -> _ProgressFraction { + let simplified = _ProgressFraction._simplify(completed, total) + return _ProgressFraction(completed: simplified.0, total: simplified.1) + } + + 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 lhs.total != 0 else { + return rhs + } + guard rhs.total != 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(lhs.total, rhs.total) { + let result = whichOverflow(lhs.completed * (lcm / lhs.total), rhs.completed * (lcm / rhs.total)) + 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() + + if let lcm = _leastCommonMultiple(lhsSimplified.total, rhsSimplified.total) { + let result = whichOverflow(lhsSimplified.completed * (lcm / lhsSimplified.total), rhsSimplified.completed * (lcm / rhsSimplified.total)) + 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: rhs.fractionCompleted * rhs.fractionCompleted, overflow: true) + } + + let newCompleted = lhs.completed.multipliedReportingOverflow(by: rhs.completed) + let newTotal = lhs.total.multipliedReportingOverflow(by: rhs.total) + + if newCompleted.overflow || newTotal.overflow { + // Try simplifying, then do it again + let lhsSimplified = lhs.simplified() + let rhsSimplified = rhs.simplified() + + let newCompletedSimplified = lhsSimplified.completed.multipliedReportingOverflow(by: rhsSimplified.completed) + let newTotalSimplified = lhsSimplified.total.multipliedReportingOverflow(by: rhsSimplified.total) + + 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) + } + + let newTotal = lhs.total.multipliedReportingOverflow(by: rhs) + + if newTotal.overflow { + let simplified = lhs.simplified() + + let newTotalSimplified = simplified.total.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.completed == rhs.completed && lhs.total == rhs.total { + return true + } else if lhs.total == rhs.total { + // Direct comparison of numerator + return lhs.completed == rhs.completed + } 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 + let left = lhs.completed.multipliedReportingOverflow(by: rhs.total) + let right = lhs.total.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() + + let leftSimplified = lhsSimplified.completed.multipliedReportingOverflow(by: rhsSimplified.total) + let rightSimplified = lhsSimplified.total.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 { + return completed >= total && completed > 0 && total > 0 + } + + + internal var fractionCompleted : Double { + return Double(completed) / Double(total) + } + + + internal var isNaN : Bool { + return total == 0 + } + + internal var debugDescription : String { + return "\(completed) / \(total) (\(fractionCompleted))" + } + + // ---- + + 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? + // And we choose to use 131072 for N + let denominator : Int = 131072 + 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..987e1a6e9 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Interop.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +@available(FoundationPreview 6.2, *) +//MARK: Progress Parent - ProgressManager Child Interop +// Actual Progress Parent +// Ghost Progress Parent +// Ghost ProgressManager Child +// Actual ProgressManager Child +extension Progress { + + /// Returns a Subprogress which can be passed to any method that reports progress + /// and can be initialized into a child `ProgressManager` to the `self`. + /// + /// 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 ghost parent & add it to actual parent's children list + let ghostProgressParent = Progress(totalUnitCount: Int64(count)) + self.addChild(ghostProgressParent, withPendingUnitCount: Int64(count)) + + // Make ghost child + let ghostReporterChild = ProgressManager(totalCount: count) + + // Make observation instance + let observation = _ProgressParentProgressManagerChild(ghostParent: ghostProgressParent, ghostChild: ghostReporterChild) + + // Make actual child with ghost child being parent + var actualProgress = ghostReporterChild.subprogress(assigningCount: count) + actualProgress.observation = observation + actualProgress.ghostReporter = ghostReporterChild + actualProgress.interopWithProgressParent = true + return actualProgress + } + + + /// 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) { + + // Need to detect cycle here + precondition(self.isCycle(reporter: reporter) == false, "Creating a cycle is not allowed.") + + // Make intermediary & add it to NSProgress parent's children list + let ghostProgressParent = Progress(totalUnitCount: Int64(reporter.manager.totalCount ?? 0)) + ghostProgressParent.completedUnitCount = Int64(reporter.manager.completedCount) + self.addChild(ghostProgressParent, withPendingUnitCount: Int64(count)) + + // Make observation instance + let observation = _ProgressParentProgressReporterChild(intermediary: ghostProgressParent, reporter: reporter) + + reporter.manager.setInteropObservationForMonitor(observation: observation) + reporter.manager.setMonitorInterop(to: true) + } + + // MARK: Cycle detection + private func isCycle(reporter: ProgressReporter, visited: Set = []) -> Bool { + if self._parent() == nil { + return false + } + + if !(self._parent() is _NSProgressParentBridge) { + return self._parent().isCycle(reporter: reporter) + } + + // then check against ProgressManager + let unwrappedParent = (self._parent() as? _NSProgressParentBridge)?.actualParent + if let unwrappedParent = unwrappedParent { + if unwrappedParent === reporter.manager { + return true + } + let updatedVisited = visited.union([unwrappedParent]) + return unwrappedParent.isCycleInterop(visited: updatedVisited) + } + return false + } +} + +private final class _ProgressParentProgressManagerChild: Sendable { + private let ghostParent: Progress + private let ghostChild: ProgressManager + + fileprivate init(ghostParent: Progress, ghostChild: ProgressManager) { + self.ghostParent = ghostParent + self.ghostChild = ghostChild + + // Set up mirroring observation relationship between ghostChild and ghostParent + // - Ghost Parent should mirror values from Ghost Child, and Ghost Child just mirrors values of Actual Child + ghostChild.addObserver { [weak self] observerState in + guard let self else { + return + } + + switch observerState { + case .totalCountUpdated: + self.ghostParent.totalUnitCount = Int64(self.ghostChild.totalCount ?? 0) + + case .fractionUpdated: + let count = self.ghostChild.withProperties { p in + return (p.completedCount, p.totalCount) + } + self.ghostParent.completedUnitCount = Int64(count.0) + self.ghostParent.totalUnitCount = Int64(count.1 ?? 0) + } + } + } +} + +private final class _ProgressParentProgressReporterChild: Sendable { + private let intermediary: Progress + private let reporter: ProgressReporter + + fileprivate init(intermediary: Progress, reporter: ProgressReporter) { + self.intermediary = intermediary + self.reporter = reporter + + reporter.manager.addObserver { [weak self] observerState in + guard let self else { + return + } + + switch observerState { + case .totalCountUpdated: + self.intermediary.totalUnitCount = Int64(self.reporter.manager.totalCount ?? 0) + + case .fractionUpdated: + let count = self.reporter.manager.withProperties { p in + return (p.completedCount, p.totalCount) + } + self.intermediary.completedUnitCount = Int64(count.0) + self.intermediary.totalUnitCount = Int64(count.1 ?? 0) + } + } + } + +} + +@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.") + + let parentBridge = _NSProgressParentBridge(managerParent: self, progressChild: progress, portion: count) + progress._setParent(parentBridge, portion: Int64(count)) + + // Save ghost parent in ProgressManager so it doesn't go out of scope after assign method ends + // So that when NSProgress increases completedUnitCount and queries for parent there is still a reference to ghostParent and parent doesn't show 0x0 (portion: 5) + self.setParentBridge(parentBridge: parentBridge) + } +} + +// Subclass of Foundation.Progress +internal final class _NSProgressParentBridge: Progress, @unchecked Sendable { + + internal let actualParent: ProgressManager + internal let actualChild: Progress + internal let ghostChild: ProgressManager + + init(managerParent: ProgressManager, progressChild: Progress, portion: Int) { + self.actualParent = managerParent + self.actualChild = progressChild + self.ghostChild = ProgressManager(totalCount: Int(progressChild.totalUnitCount)) + super.init(parent: nil, userInfo: nil) + + // Make ghostChild mirror progressChild, ghostChild is added as a child to managerParent + ghostChild.withProperties { properties in + properties.completedCount = Int(progressChild.completedUnitCount) + } + + managerParent.addToChildren(child: ghostChild, portion: portion, childFraction: _ProgressFraction(completed: Int(completedUnitCount), total: Int(totalUnitCount))) + + ghostChild.addParent(parent: managerParent, portionOfParent: portion) + } + + // 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) { +// actualParent.updateChildFraction(from: _ProgressFraction(nsProgressFraction: fraction.previous), to: _ProgressFraction(nsProgressFraction: fraction.next), portion: Int(portion)) + actualParent.updateChildState(child: ghostChild, fraction: _ProgressFraction(nsProgressFraction: fraction.next)) + } +} +#endif diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties.swift new file mode 100644 index 000000000..f6de74baa --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager+Properties.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + // 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 static var defaultValue: Int { return 0 } + + public typealias T = Int + } + + /// The number of completed files. + public var completedFileCount: CompletedFileCount.Type { CompletedFileCount.self } + public struct CompletedFileCount: Sendable, Property { + public static var defaultValue: Int { return 0 } + + public typealias T = Int + } + + /// The total number of bytes. + public var totalByteCount: TotalByteCount.Type { TotalByteCount.self } + public struct TotalByteCount: Sendable, Property { + public static var defaultValue: Int64 { return 0 } + + public typealias T = Int64 + } + + /// The number of completed bytes. + public var completedByteCount: CompletedByteCount.Type { CompletedByteCount.self } + public struct CompletedByteCount: Sendable, Property { + public static var defaultValue: Int64 { return 0 } + + public typealias T = Int64 + } + + /// The throughput, in bytes per second. + public var throughput: Throughput.Type { Throughput.self } + public struct Throughput: Sendable, Property { + public static var defaultValue: Int64 { return 0 } + + public typealias T = Int64 + } + + /// The amount of time remaining in the processing of files. + public var estimatedTimeRemaining: EstimatedTimeRemaining.Type { EstimatedTimeRemaining.self } + public struct EstimatedTimeRemaining: Sendable, Property { + public static var defaultValue: Duration { return Duration.seconds(0) } + + public typealias T = Duration + } + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift b/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift new file mode 100644 index 000000000..2e914875a --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressManager.swift @@ -0,0 +1,799 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +#if canImport(CollectionsInternal) +internal import CollectionsInternal +#elseif canImport(OrderedCollections) +internal import OrderedCollections +#elseif canImport(_FoundationCollections) +internal import _FoundationCollections +#endif + +internal struct AnyMetatypeWrapper: Hashable, Equatable, Sendable { + let metatype: Any.Type + + internal static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.metatype == rhs.metatype + } + + internal func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(metatype)) + } +} + +@available(FoundationPreview 6.2, *) +// ProgressManager +/// An object that conveys ongoing progress to the user for a specified task. +@Observable public final class ProgressManager: Sendable { + + // Stores all the state of properties + internal struct State { + var interopChild: ProgressManager? // read from this if self is actually an interop ghost + var isDirty: Bool + var dirtyChildren: Set = Set() + var indeterminate: Bool + var selfFraction: _ProgressFraction + var overallFraction: _ProgressFraction { + var overallFraction = selfFraction + for child in children { + overallFraction = overallFraction + (_ProgressFraction(completed: child.value.portionOfSelf, total: selfFraction.total) * child.value.childFraction) + } + return overallFraction + } + var children: [ProgressManager: ChildState] // My children and their information in relation to me + var parents: [ProgressManager: Int] // My parents and their information in relation to me, how much of their totalCount I am a part of + var otherProperties: [AnyMetatypeWrapper: (any Sendable)] + var childrenOtherProperties: [AnyMetatypeWrapper: OrderedDictionary] // Type: Metatype maps to dictionary of child to value + } + + internal struct ChildState { + var portionOfSelf: Int // Portion of my totalCount that this child accounts for + var childFraction: _ProgressFraction // Fraction adjusted based on portion of self; If not dirty, overallFraction should be composed of this + } + + private let state: LockedState + + // Interop states + internal enum ObserverState { + case fractionUpdated + case totalCountUpdated + } + + // Interop properties - Just kept alive + internal let interopObservation: (any Sendable)? // set at init + internal let interopObservationForMonitor: LockedState<(any Sendable)?> = LockedState(initialState: nil) + internal let monitorInterop: LockedState = LockedState(initialState: false) + +#if FOUNDATION_FRAMEWORK + internal let parentBridge: LockedState = LockedState(initialState: nil) // dummy, set upon calling setParentBridge +#endif + // Interop properties - Actually set and called + internal let ghostReporter: ProgressManager? // set at init, used to call notify observers + internal let observers: LockedState<[@Sendable (ObserverState) -> Void]> = LockedState(initialState: [])// storage for all observers, set upon calling addObservers + + /// The total units of work. + public var totalCount: Int? { + _$observationRegistrar.access(self, keyPath: \.totalCount) + return state.withLock { state in + getTotalCount(state: &state) + } + } + + /// 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 + getCompletedCount(state: &state) + } + } + + /// 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 + getFractionCompleted(state: &state) + } + + } + + /// 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 + getIsIndeterminate(state: &state) + } + } + + /// 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 + getIsFinished(state: &state) + } + } + + + /// 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) + } + + /// A type that conveys task-specific information on progress. + public protocol Property { + + associatedtype Value: Sendable, Hashable, Equatable + + /// The default value to return when property is not set to a specific value. + static var defaultValue: Value { get } + } + + /// A container that holds values for properties that specify information on progress. + @dynamicMemberLookup + public struct Values : Sendable { + //TODO: rdar://149225947 Non-escapable conformance + let manager: ProgressManager + var state: State + + /// The total units of work. + public var totalCount: Int? { + mutating get { + manager.getTotalCount(state: &state) + } + + set { + // if newValue is nil, reset indeterminate to true + if newValue != nil { + state.indeterminate = false + } else { + state.indeterminate = true + } + state.selfFraction.total = newValue ?? 0 + manager.markDirty(state: &state) + + manager.ghostReporter?.notifyObservers(with: .totalCountUpdated) + manager.monitorInterop.withLock { [manager] interop in + if interop == true { + manager.notifyObservers(with: .totalCountUpdated) + } + } + } + } + + + /// The completed units of work. + public var completedCount: Int { + mutating get { + manager.getCompletedCount(state: &state) + } + + set { + //TODO: Update self completedCount and notify parents that I am dirty + state.selfFraction.completed = newValue + manager.markDirty(state: &state) + + manager.ghostReporter?.notifyObservers(with: .fractionUpdated) + + manager.monitorInterop.withLock { [manager] interop in + if interop == true { + manager.notifyObservers(with: .fractionUpdated) + } + } + } + } + + /// Returns a property value that a key path indicates. If value is not defined, returns property's `defaultValue`. + public subscript(dynamicMember key: KeyPath) -> P.Value { + get { + return state.otherProperties[AnyMetatypeWrapper(metatype: P.self)] as? P.Value ?? P.self.defaultValue + } + + set { + // Update my own other properties entry + state.otherProperties[AnyMetatypeWrapper(metatype: P.self)] = newValue + + // Generate an array of myself + children values of the property + let flattenedChildrenValues: [P.Value] = { + let childrenDictionary = state.childrenOtherProperties[AnyMetatypeWrapper(metatype: P.self)] + var childrenValues: [P.Value] = [] + if let dictionary = childrenDictionary { + for (_, value) in dictionary { + if let value = value as? [P.Value] { + childrenValues.append(contentsOf: value) + } + } + } + return childrenValues + }() + + // Send the array of myself + children values of property to parents + let updateValueForParent: [P.Value] = [newValue] + flattenedChildrenValues + for (parent, _) in state.parents { + parent.updateChildrenOtherProperties(property: P.self, child: manager, value: updateValueForParent) + } + + } + } + } + + internal init(total: Int?, ghostReporter: ProgressManager?, interopObservation: (any Sendable)?) { + let state = State( + interopChild: nil, + isDirty: false, + dirtyChildren: Set(), + indeterminate: total == nil ? true : false, + selfFraction: _ProgressFraction(completed: 0, total: total ?? 0), + children: [:], + parents: [:], + otherProperties: [:], + childrenOtherProperties: [:] + ) + self.state = LockedState(initialState: state) + self.interopObservation = interopObservation + self.ghostReporter = ghostReporter + } + + /// Initializes `self` with `totalCount`. + /// + /// If `totalCount` is set to `nil`, `self` is indeterminate. + /// - Parameter totalCount: Total units of work. + public convenience init(totalCount: Int?) { + self.init(total: totalCount, ghostReporter: nil, interopObservation: nil) + } + + /// 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.") + + // get the actual progress from within the reporter, then add as children + let actualManager = reporter.manager + + // Add reporter as child + Add self as parent + self.addToChildren(child: actualManager, portion: count, childFraction: actualManager.getProgressFraction()) + actualManager.addParent(parent: self, portionOfParent: count) + } + + /// Increases `completedCount` by `count`. + /// - Parameter count: Units of work. + public func complete(count: Int) { + // Update self fraction + mark dirty + state.withLock { state in + state.selfFraction.completed += count + markDirty(state: &state) + } + + // Interop updates stuff + ghostReporter?.notifyObservers(with: .fractionUpdated) + monitorInterop.withLock { [self] interop in + if interop == true { + notifyObservers(with: .fractionUpdated) + } + } + } + + /// Returns an array of values for specified property in subtree. + /// - Parameter metatype: Type of property. + /// - Returns: Array of values for property. + public func values(of property: P.Type) -> [P.Value] { + _$observationRegistrar.access(self, keyPath: \.state) + return state.withLock { state in + let childrenValues = getFlattenedChildrenValues(property: property, state: &state) + return [state.otherProperties[AnyMetatypeWrapper(metatype: property)] as? P.Value ?? P.defaultValue] + childrenValues.map { $0 ?? P.defaultValue } + } + } + + + /// Returns the aggregated result of values. + /// - Parameters: + /// - property: Type of property. + public func total(of property: P.Type) -> P.Value where P.Value: AdditiveArithmetic { + let droppedNil = values(of: property).compactMap { $0 } + return droppedNil.reduce(P.Value.zero, +) + } + + /// 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(manager: self, state: state) + // This is done to avoid copy on write later + state = State( + isDirty: false, + indeterminate: true, + selfFraction: _ProgressFraction(), + children: [:], + parents: [:], + otherProperties: [:], + childrenOtherProperties: [:]) + let result = try closure(&values) + state = values.state + return result + } + } + + //MARK: ProgressManager Properties getters + internal func getProgressFraction() -> _ProgressFraction { + return state.withLock { state in + return state.selfFraction + } + } + + /// Returns nil if `self` was instantiated without total units; + /// returns a `Int` value otherwise. + private func getTotalCount(state: inout State) -> Int? { + if let interopChild = state.interopChild { + return interopChild.totalCount + } + if state.indeterminate { + return nil + } else { + return state.selfFraction.total + } + } + + /// Returns 0 if `self` has `nil` total units; + /// returns a `Int` value otherwise. + private func getCompletedCount(state: inout State) -> Int { + if let interopChild = state.interopChild { + return interopChild.completedCount + } + // Implementation thoughts: + // If self is dirty, that just means I got mutated and my parents haven't received updates. + // If my dirtyChildren list exists, that just means I have fractional updates from children, which might not have completed. + // If at least one of my dirtyChildren actually completed, that means I would need to update my completed count actually. + + // If there are dirty children, get updates first + if state.dirtyChildren.count > 0 { + + // Get dirty leaves + var dirtyLeaves: [ProgressManager] = [] + collectDirtyNodes(dirtyNodes: &dirtyLeaves, state: &state) + + // Then ask each dirty leaf to propagate values up + for leaf in dirtyLeaves { + leaf.updateState(exclude: self, lockedState: &state) + } + } + + // Return the actual completedCount + return state.selfFraction.completed + } + + /// Returns 0.0 if `self` has `nil` total units; + /// returns a `Double` otherwise. + /// If `indeterminate`, return 0.0. + /// + /// The calculation of fraction completed for a ProgressManager instance that has children + /// will take into account children's fraction completed as well. + private func getFractionCompleted(state: inout State) -> Double { + // Implementation thoughts: + // If my self is dirty, that means I got mutated and I have parents that haven't received updates from me. + // If my dirtyChildren list exists, that means I have fractional updates from these children, and I need these fractional updates. + // But this runs into the issue of updating only the queried branch, but not the other branch that is not queried but dirty, this would cause the leaf to be cleaned up, but the other branch which share the dirty leaf hasn't received any updates. + + // If I am clean leaf and has no dirtyChildren, directly return fractionCompleted - no need to do recalculation whenn unnecessary + // If I am dirty leaf and no dirtyChildren, directly return fractionCompleted - no need to do recalculation when unnecessary + // If I am dirty leaf and also has dirtyChildren - get updates + // If I am clean leaf and has dirtyChildren - get updates + + // Interop child + if let interopChild = state.interopChild { + return interopChild.fractionCompleted + } + + // Indeterminate + if state.indeterminate { + return 0.0 + } + + // If there are dirty children, get updates first + if state.dirtyChildren.count > 0 { + + // Get dirty leaves + var dirtyLeaves: [ProgressManager] = [] + collectDirtyNodes(dirtyNodes: &dirtyLeaves, state: &state) + + // Then ask each dirty leaf to propagate values up + for leaf in dirtyLeaves { + leaf.updateState(exclude: self, lockedState: &state) + } + } + + return state.overallFraction.fractionCompleted + } + + /// Returns `true` if completed and total units are not `nil` and completed units is greater than or equal to total units; + /// returns `false` otherwise. + private func getIsFinished(state: inout State) -> Bool { + return state.selfFraction.isFinished + } + + + /// Returns `true` if `self` has `nil` total units. + private func getIsIndeterminate(state: inout State) -> Bool { + return state.indeterminate + } + + //MARK: FractionCompleted Calculation methods + private struct UpdateState { + let previous: _ProgressFraction + let current: _ProgressFraction + } + + /// If parents exist, mark self as dirty and add self to parents' dirty children list. + private func markDirty(state: inout State) { + if state.parents.count > 0 { + state.isDirty = true + } + + // Recursively add self as dirty child to parents list + for (parent, _) in state.parents { + parent.addDirtyChild(self) + } + + } + + /// Add a given child to self's dirty children list. + private func addDirtyChild(_ child: ProgressManager) { + state.withLock { state in + // Child already exists in dirty children + if state.dirtyChildren.contains(child) { + return + } + + state.dirtyChildren.insert(child) + + // Propagate dirty state up to parents + for (parent, _) in state.parents { + parent.addDirtyChild(self) + } + } + } + + /// Collect bottommost dirty nodes in a subtree, when called from locked context. + private func collectDirtyNodes(dirtyNodes: inout [ProgressManager], state: inout State) { + if state.dirtyChildren.isEmpty && state.isDirty { + dirtyNodes += [self] + } else { + for child in state.dirtyChildren { + child.collectDirtyNodes(dirtyNodes: &dirtyNodes) + } + } + } + + /// Collect bottommost dirty nodes in a subtree, when called directly. + private func collectDirtyNodes(dirtyNodes: inout [ProgressManager]) { + state.withLock { state in + if state.dirtyChildren.isEmpty && state.isDirty { + dirtyNodes += [self] + } else { + for child in state.dirtyChildren { + child.collectDirtyNodes(dirtyNodes: &dirtyNodes) + } + } + } + } + + /// Updates the state of current ProgressManager by setting isDirty to false. + private func updateState(exclude lockedRoot: ProgressManager?, lockedState: inout State) { + // If I am the root which was queried. + if self === lockedRoot { + lockedState.isDirty = false + + for (parent, _) in lockedState.parents { + parent.updateChildState(exclude: lockedRoot, lockedState: &lockedState, child: self, fraction: lockedState.overallFraction) + } + + return + } + + state.withLock { state in + // Set isDirty to false + state.isDirty = false + + // Propagate these changes up to parent + for (parent, _) in state.parents { + parent.updateChildState(exclude: lockedRoot, lockedState: &lockedState, child: self, fraction: state.overallFraction) + } + } + } + + /// Updates the stored state of a child, accounting for whether or not child has completed. + internal func updateChildState(exclude lockedRoot: ProgressManager?, lockedState: inout State, child: ProgressManager, fraction: _ProgressFraction) { + if self === lockedRoot { + lockedState.children[child]?.childFraction = fraction + lockedState.dirtyChildren.remove(child) + + if fraction.isFinished { + lockedState.selfFraction.completed += lockedState.children[child]?.portionOfSelf ?? 0 + lockedState.children.removeValue(forKey: child) + } + + updateState(exclude: self, lockedState: &lockedState) + + return + } + + state.withLock { state in + state.children[child]?.childFraction = fraction + state.dirtyChildren.remove(child) + + if fraction.isFinished { + state.selfFraction.completed += state.children[child]?.portionOfSelf ?? 0 + state.children.removeValue(forKey: child) + } + } + + updateState(exclude: lockedRoot, lockedState: &lockedState) + } + + internal func updateState() { + state.withLock { state in + // Set isDirty to false + state.isDirty = false + + // Propagate these changes up to parent + for (parent, _) in state.parents { + parent.updateChildState(child: self, fraction: state.overallFraction) + } + } + } + + internal func updateChildState(child: ProgressManager, fraction: _ProgressFraction) { + state.withLock { state in + state.children[child]?.childFraction = fraction + state.dirtyChildren.remove(child) + + if fraction.isFinished { + state.selfFraction.completed += state.children[child]?.portionOfSelf ?? 0 + state.children.removeValue(forKey: child) + } + } + + updateState() + } + + //MARK: Interop-related internal methods + /// Adds `observer` to list of `_observers` in `self`. + internal func addObserver(observer: @escaping @Sendable (ObserverState) -> Void) { + observers.withLock { observers in + observers.append(observer) + } + } + + /// Notifies all `_observers` of `self` when `state` changes. + private func notifyObservers(with state: ObserverState) { + observers.withLock { observers in + for observer in observers { + observer(state) + } + } + } + + internal func setInteropObservationForMonitor(observation monitorObservation: (any Sendable)) { + interopObservationForMonitor.withLock { observation in + observation = monitorObservation + } + } + + internal func setMonitorInterop(to value: Bool) { + monitorInterop.withLock { monitorInterop in + monitorInterop = value + } + } + + //MARK: Internal methods to mutate locked context +#if FOUNDATION_FRAMEWORK + internal func setParentBridge(parentBridge: Foundation.Progress) { + self.parentBridge.withLock { bridge in + bridge = parentBridge + } + } +#endif + + internal func setInteropChild(interopChild: ProgressManager) { + state.withLock { state in + state.interopChild = interopChild + } + } + + // Adds a child to the children list, with all of the info fields populated + internal func addToChildren(child: ProgressManager, portion: Int, childFraction: _ProgressFraction) { + state.withLock { state in + let childState = ChildState(portionOfSelf: portion, childFraction: childFraction) + state.children[child] = childState + + // Add child to dirtyChildren list + state.dirtyChildren.insert(child) + } + } + + internal func addParent(parent: ProgressManager, portionOfParent: Int) { + state.withLock { state in + state.parents[parent] = portionOfParent + + // Update metatype entry in parent + for (metatype, value) in state.otherProperties { + let childrenValues = getFlattenedChildrenValues(property: metatype, state: &state) + let updatedParentEntry: [(any Sendable)?] = [value] + childrenValues + parent.updateChildrenOtherPropertiesAnyValue(property: metatype, child: self, value: updatedParentEntry) + } + } + } + + internal func getAdditionalProperties( + _ closure: (sending Values) throws(E) -> sending T + ) throws(E) -> sending T { + try state.withLock { state throws(E) -> T in + let values = Values(manager: self, state: state) + // No need to modify state since this is read-only + let result = try closure(values) + // No state update after closure execution + return result + } + } + + // MARK: Propagation of Additional Properties Methods (Dual Mode of Operations) + private func getFlattenedChildrenValues(property metatype: P.Type, state: inout State) -> [P.Value?] { + let childrenDictionary = state.childrenOtherProperties[AnyMetatypeWrapper(metatype: metatype)] + var childrenValues: [P.Value?] = [] + if let dictionary = childrenDictionary { + for (_, value) in dictionary { + if let value = value as? [P.Value?] { + childrenValues.append(contentsOf: value) + } + } + } + return childrenValues + } + + private func getFlattenedChildrenValues(property metatype: AnyMetatypeWrapper, state: inout State) -> [(any Sendable)?] { + let childrenDictionary = state.childrenOtherProperties[metatype] + var childrenValues: [(any Sendable)?] = [] + if let dictionary = childrenDictionary { + for (_, value) in dictionary { + childrenValues.append(contentsOf: value) + } + } + return childrenValues + } + + private func updateChildrenOtherPropertiesAnyValue(property metatype: AnyMetatypeWrapper, child: ProgressManager, value: [(any Sendable)?]) { + state.withLock { state in + let myEntries = state.childrenOtherProperties[metatype] + if myEntries != nil { + // If entries is not nil, then update my entry of children values + state.childrenOtherProperties[metatype]![child] = value + } else { + // If entries is nil, initialize then update my entry of children values + state.childrenOtherProperties[metatype] = [:] + state.childrenOtherProperties[metatype]![child] = value + } + // Ask parent to update their entry with my value + new children value + let childrenValues = getFlattenedChildrenValues(property: metatype, state: &state) + let updatedParentEntry: [(any Sendable)?] = [state.otherProperties[metatype]] + childrenValues + for (parent, _) in state.parents { + parent.updateChildrenOtherPropertiesAnyValue(property: metatype, child: child, value: updatedParentEntry) + } + } + } + + private func updateChildrenOtherProperties(property metatype: P.Type, child: ProgressManager, value: [P.Value?]) { + state.withLock { state in + let myEntries = state.childrenOtherProperties[AnyMetatypeWrapper(metatype: metatype)] + if myEntries != nil { + // If entries is not nil, then update my entry of children values + state.childrenOtherProperties[AnyMetatypeWrapper(metatype: metatype)]![child] = value + } else { + // If entries is nil, initialize then update my entry of children values + state.childrenOtherProperties[AnyMetatypeWrapper(metatype: metatype)] = [:] + state.childrenOtherProperties[AnyMetatypeWrapper(metatype: metatype)]![child] = value + } + // Ask parent to update their entry with my value + new children value + let childrenValues = getFlattenedChildrenValues(property: metatype, state: &state) + let updatedParentEntry: [P.Value?] = [state.otherProperties[AnyMetatypeWrapper(metatype: metatype)] as? P.Value] + childrenValues + + for (parent, _) in state.parents { + parent.updateChildrenOtherProperties(property: metatype, child: self, value: updatedParentEntry) + } + + } + } + + // MARK: Cycle detection + func isCycle(reporter: ProgressReporter, visited: Set = []) -> Bool { + if reporter.manager === self { + return true + } + + let updatedVisited = visited.union([self]) + + return state.withLock { state in + for (parent, _) in state.parents { + if !updatedVisited.contains(parent) { + if parent.isCycle(reporter: reporter, visited: updatedVisited) { + return true + } + } + } + return false + } + } + + func isCycleInterop(visited: Set = []) -> Bool { + return state.withLock { state in + for (parent, _) in state.parents { + if !visited.contains(parent) { + if 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 + } + } + } + } +} + +@available(FoundationPreview 6.2, *) +// Hashable & Equatable Conformance +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: CustomDebugStringConvertible { + /// The description for `completedCount` and `totalCount`. + public var debugDescription: String { + return "\(completedCount) / \(totalCount ?? 0)" + } +} diff --git a/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift b/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift new file mode 100644 index 000000000..f98d56e10 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/ProgressReporter.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + + /// 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 + } + + /// 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.getAdditionalProperties(closure) + } + + /// Returns an array of values for specified property in subtree. + /// - Parameter metatype: Type of property. + /// - Returns: Array of values for property. + public func values(of property: P.Type) -> [P.Value] { + manager.values(of: property) + } + + /// Returns the aggregated result of values. + /// - Parameters: + /// - property: Type of property. + public func total(of property: P.Type) -> P.Value where P.Value: AdditiveArithmetic { + manager.total(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..8d850ce38 --- /dev/null +++ b/Sources/FoundationEssentials/ProgressManager/Subprogress.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// 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 + + // Interop variables for Progress - ProgressManager Interop + internal var interopWithProgressParent: Bool = false + // To be kept alive in ProgressManager + internal var observation: (any Sendable)? + internal var ghostReporter: ProgressManager? + + internal init(parent: ProgressManager, portionOfParent: Int) { + self.parent = parent + self.portionOfParent = portionOfParent + self.isInitializedToProgressReporter = false + } + + /// 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 + + let childManager = ProgressManager(total: totalCount, ghostReporter: ghostReporter, interopObservation: observation) + + if interopWithProgressParent { + // Set interop child of ghost manager so ghost manager reads from here + ghostReporter?.setInteropChild(interopChild: childManager) + } else { + // Add child to parent's _children list & Store in child children's position in parent + parent.addToChildren(child: childManager, portion: portionOfParent, childFraction: childManager.getProgressFraction()) + childManager.addParent(parent: parent, portionOfParent: portionOfParent) + } + + 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..42e5211e7 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressFractionTests.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest + +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#else +@testable import FoundationEssentials +#endif // FOUNDATION_FRAMEWORK + +final class ProgressFractionTests: XCTestCase { + func test_equal() { + let f1 = _ProgressFraction(completed: 5, total: 10) + let f2 = _ProgressFraction(completed: 100, total: 200) + + XCTAssertEqual(f1, f2) + + let f3 = _ProgressFraction(completed: 3, total: 10) + XCTAssertNotEqual(f1, f3) + + let f4 = _ProgressFraction(completed: 5, total: 10) + XCTAssertEqual(f1, f4) + } + + func test_addSame() { + let f1 = _ProgressFraction(completed: 5, total: 10) + let f2 = _ProgressFraction(completed: 3, total: 10) + + let r = f1 + f2 + XCTAssertEqual(r.completed, 8) + XCTAssertEqual(r.total, 10) + } + + func test_addDifferent() { + let f1 = _ProgressFraction(completed: 5, total: 10) + let f2 = _ProgressFraction(completed : 300, total: 1000) + + let r = f1 + f2 + XCTAssertEqual(r.completed, 800) + XCTAssertEqual(r.total, 1000) + } + + func test_subtract() { + let f1 = _ProgressFraction(completed: 5, total: 10) + let f2 = _ProgressFraction(completed: 3, total: 10) + + let r = f1 - f2 + XCTAssertEqual(r.completed, 2) + XCTAssertEqual(r.total, 10) + } + + func test_multiply() { + let f1 = _ProgressFraction(completed: 5, total: 10) + let f2 = _ProgressFraction(completed: 1, total: 2) + + let r = f1 * f2 + XCTAssertEqual(r.completed, 5) + XCTAssertEqual(r.total, 20) + } + + func test_simplify() { + let f1 = _ProgressFraction(completed: 5, total: 10) + let f2 = _ProgressFraction(completed: 3, total: 10) + + let r = (f1 + f2).simplified() + + XCTAssertEqual(r.completed, 4) + XCTAssertEqual(r.total, 5) + } + + func test_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) + } + + XCTAssertEqual(fractionResult, expectedResult, accuracy: 0.00001) + } + + func test_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 + XCTAssertTrue(f1.overflowed) + + let f2 = _ProgressFraction(completed: 1, total: 4) + f1 + + // f2 should also be in overflow + XCTAssertTrue(f2.overflowed) + + // And it should have completed value of about 1.0/4.0 + f1.fractionCompleted + let expected = (1.0 / 4.0) + f1.fractionCompleted + + XCTAssertEqual(expected, f2.fractionCompleted, accuracy: 0.00001) + } + +#if _pointerBitWidth(_64) // These tests assumes Int is Int64 + func test_andAndSubtractOverflow() { + 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 + XCTAssertTrue(result1.completed > 0) + + let result2 = (f3 - f2) + f1 + XCTAssertTrue(result2.completed < 60) + } +#endif + + func test_fractionFromDouble() { + let d = 4.25 // exactly representable in binary + let f1 = _ProgressFraction(double: d) + + let simplified = f1.simplified() + XCTAssertEqual(simplified.completed, 17) + XCTAssertEqual(simplified.total, 4) + } + + func test_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 + XCTAssertFalse(r.overflowed) + } +} diff --git a/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift new file mode 100644 index 000000000..e80e88fb4 --- /dev/null +++ b/Tests/FoundationEssentialsTests/ProgressManager/ProgressManagerTests.swift @@ -0,0 +1,725 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest + +#if FOUNDATION_FRAMEWORK +@testable import Foundation +#else +@testable import FoundationEssentials +#endif // FOUNDATION_FRAMEWORK + +/// Unit tests for basic functionalities of ProgressManager +class TestProgressManager: XCTestCase { + /// 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) + XCTAssertEqual(manager.completedCount, i) + XCTAssertEqual(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) + XCTAssertEqual(manager.completedCount, i) + XCTAssertEqual(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) + XCTAssertEqual(manager.completedCount, i) + XCTAssertEqual(manager.fractionCompleted, Double(i) / Double(11)) + } + } + + /// MARK: Tests calculations based on change in totalCount + func testTotalCountNil() async throws { + let overall = ProgressManager(totalCount: nil) + overall.complete(count: 10) + XCTAssertEqual(overall.completedCount, 10) + XCTAssertEqual(overall.fractionCompleted, 0.0) + XCTAssertTrue(overall.isIndeterminate) + XCTAssertNil(overall.totalCount) + } + + func testTotalCountReset() async throws { + let overall = ProgressManager(totalCount: 10) + overall.complete(count: 5) + XCTAssertEqual(overall.completedCount, 5) + XCTAssertEqual(overall.totalCount, 10) + XCTAssertEqual(overall.fractionCompleted, 0.5) + XCTAssertFalse(overall.isIndeterminate) + + overall.withProperties { p in + p.totalCount = nil + p.completedCount += 1 + } + XCTAssertEqual(overall.completedCount, 6) + XCTAssertNil(overall.totalCount) + XCTAssertEqual(overall.fractionCompleted, 0.0) + XCTAssertTrue(overall.isIndeterminate) + XCTAssertFalse(overall.isFinished) + + overall.withProperties { p in + p.totalCount = 12 + p.completedCount += 2 + } + XCTAssertEqual(overall.completedCount, 8) + XCTAssertEqual(overall.totalCount, 12) + XCTAssertEqual(overall.fractionCompleted, Double(8) / Double(12)) + XCTAssertFalse(overall.isIndeterminate) + XCTAssertFalse(overall.isFinished) + } + + func testTotalCountNilWithChild() async throws { + let overall = ProgressManager(totalCount: nil) + XCTAssertEqual(overall.completedCount, 0) + XCTAssertNil(overall.totalCount) + XCTAssertEqual(overall.fractionCompleted, 0.0) + XCTAssertTrue(overall.isIndeterminate) + XCTAssertFalse(overall.isFinished) + + let progress1 = overall.subprogress(assigningCount: 2) + let manager1 = progress1.start(totalCount: 1) + + manager1.complete(count: 1) + XCTAssertEqual(manager1.totalCount, 1) + XCTAssertEqual(manager1.completedCount, 1) + XCTAssertEqual(manager1.fractionCompleted, 1.0) + XCTAssertFalse(manager1.isIndeterminate) + XCTAssertTrue(manager1.isFinished) + + XCTAssertEqual(overall.completedCount, 2) + XCTAssertEqual(overall.totalCount, nil) + XCTAssertEqual(overall.fractionCompleted, 0.0) + XCTAssertTrue(overall.isIndeterminate) + XCTAssertFalse(overall.isFinished) + + overall.withProperties { p in + p.totalCount = 5 + } + XCTAssertEqual(overall.completedCount, 2) + XCTAssertEqual(overall.totalCount, 5) + XCTAssertEqual(overall.fractionCompleted, 0.4) + XCTAssertFalse(overall.isIndeterminate) + XCTAssertFalse(overall.isFinished) + } + + func testTotalCountFinishesWithLessCompletedCount() 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) + + XCTAssertEqual(overall.completedCount, 13) + XCTAssertEqual(overall.totalCount, 10) + XCTAssertEqual(overall.fractionCompleted, 1.3) + XCTAssertFalse(overall.isIndeterminate) + XCTAssertTrue(overall.isFinished) + } + + /// MARK: Tests single-level tree + func testDiscreteReporter() async throws { + let manager = ProgressManager(totalCount: 3) + await doBasicOperationV1(reportTo: manager.subprogress(assigningCount: 3)) + XCTAssertEqual(manager.fractionCompleted, 1.0) + XCTAssertEqual(manager.completedCount, 3) + XCTAssertTrue(manager.isFinished) + } + + /// MARK: Tests multiple-level trees + func testEmptyDiscreteReporter() async throws { + let manager = ProgressManager(totalCount: nil) + XCTAssertTrue(manager.isIndeterminate) + + manager.withProperties { p in + p.totalCount = 10 + } + XCTAssertFalse(manager.isIndeterminate) + XCTAssertEqual(manager.totalCount, 10) + + await doBasicOperationV1(reportTo: manager.subprogress(assigningCount: 10)) + XCTAssertEqual(manager.fractionCompleted, 1.0) + XCTAssertEqual(manager.completedCount, 10) + XCTAssertTrue(manager.isFinished) + } + + func testTwoLevelTreeWithTwoChildren() async throws { + let overall = ProgressManager(totalCount: 2) + + await doBasicOperationV1(reportTo: overall.subprogress(assigningCount: 1)) + XCTAssertEqual(overall.fractionCompleted, 0.5) + XCTAssertEqual(overall.completedCount, 1) + XCTAssertFalse(overall.isFinished) + XCTAssertFalse(overall.isIndeterminate) + + await doBasicOperationV2(reportTo: overall.subprogress(assigningCount: 1)) + XCTAssertEqual(overall.fractionCompleted, 1.0) + XCTAssertEqual(overall.completedCount, 2) + XCTAssertTrue(overall.isFinished) + XCTAssertFalse(overall.isIndeterminate) + } + + func testTwoLevelTreeWithTwoChildrenWithOneFileProperty() 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 + } + + XCTAssertEqual(overall.fractionCompleted, 0.5) + // Parent is expected to get totalFileCount from one of the children with a totalFileCount + XCTAssertEqual(overall.withProperties(\.totalFileCount), 0) + } + + func testTwoLevelTreeWithMultipleChildren() async throws { + let overall = ProgressManager(totalCount: 3) + + await doBasicOperationV1(reportTo: overall.subprogress(assigningCount:1)) + XCTAssertEqual(overall.fractionCompleted, Double(1) / Double(3)) + XCTAssertEqual(overall.completedCount, 1) + + await doBasicOperationV2(reportTo: overall.subprogress(assigningCount:1)) + XCTAssertEqual(overall.fractionCompleted, Double(2) / Double(3)) + XCTAssertEqual(overall.completedCount, 2) + + await doBasicOperationV3(reportTo: overall.subprogress(assigningCount:1)) + XCTAssertEqual(overall.fractionCompleted, Double(3) / Double(3)) + XCTAssertEqual(overall.completedCount, 3) + } + + func testThreeLevelTree() async throws { + let overall = ProgressManager(totalCount: 100) + XCTAssertEqual(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) + + XCTAssertEqual(overall.fractionCompleted, 0.0) + + grandchildManager1.complete(count: 50) + XCTAssertEqual(manager1.fractionCompleted, 0.5) + XCTAssertEqual(overall.fractionCompleted, 0.5) + + grandchildManager1.complete(count: 50) + XCTAssertEqual(manager1.fractionCompleted, 1.0) + XCTAssertEqual(overall.fractionCompleted, 1.0) + + XCTAssertTrue(grandchildManager1.isFinished) + XCTAssertTrue(manager1.isFinished) + XCTAssertTrue(overall.isFinished) + } + + func testFourLevelTree() async throws { + let overall = ProgressManager(totalCount: 100) + XCTAssertEqual(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) + + XCTAssertEqual(overall.fractionCompleted, 0.0) + + + let greatGrandchild1 = grandchildManager1.subprogress(assigningCount: 100) + let greatGrandchildManager1 = greatGrandchild1.start(totalCount: 100) + + greatGrandchildManager1.complete(count: 50) + XCTAssertEqual(overall.fractionCompleted, 0.5) + + greatGrandchildManager1.complete(count: 50) + XCTAssertEqual(overall.fractionCompleted, 1.0) + + XCTAssertTrue(greatGrandchildManager1.isFinished) + XCTAssertTrue(grandchildManager1.isFinished) + XCTAssertTrue(manager1.isFinished) + XCTAssertTrue(overall.isFinished) + } +} + +/// Unit tests for propagation of type-safe metadata in ProgressManager tree. +class TestProgressManagerAdditionalProperties: XCTestCase { + func doFileOperation(reportTo subprogress: consuming Subprogress) async { + let manager = subprogress.start(totalCount: 100) + manager.withProperties { properties in + properties.totalFileCount = 100 + } + + XCTAssertEqual(manager.withProperties(\.totalFileCount), 100) + + manager.complete(count: 100) + XCTAssertEqual(manager.fractionCompleted, 1.0) + XCTAssertTrue(manager.isFinished) + + manager.withProperties { properties in + properties.completedFileCount = 100 + } + XCTAssertEqual(manager.withProperties(\.completedFileCount), 100) + XCTAssertEqual(manager.withProperties(\.totalFileCount), 100) + } + + func testDiscreteReporterWithFileProperties() async throws { + let fileProgressManager = ProgressManager(totalCount: 3) + await doFileOperation(reportTo: fileProgressManager.subprogress(assigningCount: 3)) + XCTAssertEqual(fileProgressManager.fractionCompleted, 1.0) + XCTAssertEqual(fileProgressManager.completedCount, 3) + XCTAssertTrue(fileProgressManager.isFinished) + XCTAssertEqual(fileProgressManager.withProperties(\.totalFileCount), 0) + XCTAssertEqual(fileProgressManager.withProperties(\.completedFileCount), 0) + + let totalFileValues = fileProgressManager.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalFileValues, [0, 100]) + + let reducedTotalFileValue = fileProgressManager.total(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(reducedTotalFileValue, 100) + + let completedFileValues = fileProgressManager.values(of: ProgressManager.Properties.CompletedFileCount.self) + XCTAssertEqual(completedFileValues, [0, 100]) + + let reducedCompletedFileValue = fileProgressManager.total(of: ProgressManager.Properties.CompletedFileCount.self) + XCTAssertEqual(reducedCompletedFileValue, 100) + } + + func testTwoLevelTreeWithOneChildWithFileProperties() 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) + + XCTAssertEqual(overall.fractionCompleted, 0.5) + + XCTAssertEqual(overall.withProperties(\.totalFileCount), 0) + XCTAssertEqual(manager1.withProperties(\.totalFileCount), 10) + XCTAssertEqual(manager1.withProperties(\.completedFileCount), 0) + + let totalFileValues = overall.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalFileValues, [0, 10]) + + let completedFileValues = overall.values(of: ProgressManager.Properties.CompletedFileCount.self) + XCTAssertEqual(completedFileValues, [0, 0]) + } + + func testTwoLevelTreeWithTwoChildrenWithFileProperties() 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 + } + + XCTAssertEqual(overall.fractionCompleted, 0.0) + XCTAssertEqual(overall.withProperties(\.totalFileCount), 0) + XCTAssertEqual(overall.withProperties(\.completedFileCount), 0) + let totalFileValues = overall.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalFileValues, [0, 11, 9]) + let completedFileValues = overall.values(of: ProgressManager.Properties.CompletedFileCount.self) + XCTAssertEqual(completedFileValues, [0, 0, 0]) + + // Update FileCounts + manager1.withProperties { properties in + properties.completedFileCount = 1 + } + + manager2.withProperties { properties in + properties.completedFileCount = 1 + } + + XCTAssertEqual(overall.withProperties(\.completedFileCount), 0) + let updatedCompletedFileValues = overall.values(of: ProgressManager.Properties.CompletedFileCount.self) + XCTAssertEqual(updatedCompletedFileValues, [0, 1, 1]) + } + + func testThreeLevelTreeWithFileProperties() 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 + } + XCTAssertEqual(childManager1.withProperties(\.totalFileCount), 10) + + let preTotalFileValues = overall.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(preTotalFileValues, [0, 0, 10]) + + let childProgress2 = manager1.subprogress(assigningCount: 2) + let childManager2 = childProgress2.start(totalCount: nil) + childManager2.withProperties { properties in + properties.totalFileCount += 10 + } + XCTAssertEqual(childManager2.withProperties(\.totalFileCount), 10) + + // Tests that totalFileCount propagates to root level + XCTAssertEqual(overall.withProperties(\.totalFileCount), 0) + let totalFileValues = overall.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalFileValues, [0, 0, 10, 10]) + + manager1.withProperties { properties in + properties.totalFileCount += 999 + } + let totalUpdatedFileValues = overall.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalUpdatedFileValues, [0, 999, 10, 10]) + } +} + +#if FOUNDATION_FRAMEWORK +/// Unit tests for interop methods that support building Progress trees with both Progress and ProgressManager +class TestProgressManagerInterop: XCTestCase { + func doSomethingWithProgress(expectation1: XCTestExpectation, expectation2: XCTestExpectation) async -> Progress { + let p = Progress(totalUnitCount: 2) + Task.detached { + p.completedUnitCount = 1 + expectation1.fulfill() + p.completedUnitCount = 2 + expectation2.fulfill() + } + return p + } + + func doSomethingWithReporter(subprogress: consuming Subprogress?) async { + let manager = subprogress?.start(totalCount: 4) + manager?.complete(count: 2) + manager?.complete(count: 2) + } + + func testInteropProgressParentProgressManagerChild() async throws { + // Initialize a Progress Parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let expectation1 = XCTestExpectation(description: "Set completed unit count to 1") + let expectation2 = XCTestExpectation(description: "Set completed unit count to 2") + let p1 = await doSomethingWithProgress(expectation1: expectation1, expectation2: expectation2) + overall.addChild(p1, withPendingUnitCount: 5) + + await fulfillment(of: [expectation1, expectation2], timeout: 10.0) + + // Check if ProgressManager values propagate to Progress parent + XCTAssertEqual(overall.fractionCompleted, 0.5) + XCTAssertEqual(overall.completedUnitCount, 5) + + // Add ProgressManager as Child + let p2 = overall.makeChild(withPendingUnitCount: 5) + await doSomethingWithReporter(subprogress: p2) + + // Check if Progress values propagate to Progress parent + XCTAssertEqual(overall.fractionCompleted, 1.0) + XCTAssertEqual(overall.completedUnitCount, 10) + } + + func testInteropProgressParentProgressReporterChildWithEmptyProgress() async throws { + // Initialize a Progress parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let expectation1 = XCTestExpectation(description: "Set completed unit count to 1") + let expectation2 = XCTestExpectation(description: "Set completed unit count to 2") + let p1 = await doSomethingWithProgress(expectation1: expectation1, expectation2: expectation2) + overall.addChild(p1, withPendingUnitCount: 5) + + await fulfillment(of: [expectation1, expectation2], timeout: 10.0) + + // Check if ProgressManager values propagate to Progress parent + XCTAssertEqual(overall.fractionCompleted, 0.5) + XCTAssertEqual(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 + XCTAssertEqual(overall.fractionCompleted, 1.0) + XCTAssertEqual(overall.completedUnitCount, 10) + } + + func testInteropProgressParentProgressReporterChildWithExistingProgress() async throws { + // Initialize a Progress parent + let overall = Progress.discreteProgress(totalUnitCount: 10) + + // Add Progress as Child + let expectation1 = XCTestExpectation(description: "Set completed unit count to 1") + let expectation2 = XCTestExpectation(description: "Set completed unit count to 2") + let p1 = await doSomethingWithProgress(expectation1: expectation1, expectation2: expectation2) + overall.addChild(p1, withPendingUnitCount: 5) + + await fulfillment(of: [expectation1, expectation2], timeout: 10.0) + + // Check if ProgressManager values propagate to Progress parent + XCTAssertEqual(overall.fractionCompleted, 0.5) + XCTAssertEqual(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 + XCTAssertEqual(overall.fractionCompleted, 1.0) + XCTAssertEqual(overall.completedUnitCount, 10) + } + + func testInteropProgressManagerParentProgressChild() async throws { + // Initialize ProgressManager parent + let overallManager = ProgressManager(totalCount: 10) + + // Add ProgressManager as Child + await doSomethingWithReporter(subprogress: overallManager.subprogress(assigningCount: 5)) + + // Check if ProgressManager values propagate to ProgressManager parent + XCTAssertEqual(overallManager.fractionCompleted, 0.5) + XCTAssertEqual(overallManager.completedCount, 5) + + // Interop: Add Progress as Child + let expectation1 = XCTestExpectation(description: "Set completed unit count to 1") + let expectation2 = XCTestExpectation(description: "Set completed unit count to 2") + let p2 = await doSomethingWithProgress(expectation1: expectation1, expectation2: expectation2) + overallManager.subprogress(assigningCount: 5, to: p2) + + await fulfillment(of: [expectation1, expectation2], timeout: 10.0) + + // Check if Progress values propagate to ProgressRerpoter parent + XCTAssertEqual(overallManager.completedCount, 10) + XCTAssertEqual(overallManager.totalCount, 10) + //TODO: Somehow this sometimes gets updated to 1.25 instead of just 1.0 + XCTAssertEqual(overallManager.fractionCompleted, 1.0) + } + + func getProgressWithTotalCountInitialized() -> Progress { + return Progress(totalUnitCount: 5) + } + + func receiveProgress(progress: consuming Subprogress) { + let _ = progress.start(totalCount: 5) + } + + func testInteropProgressManagerParentProgressChildConsistency() async throws { + let overallReporter = ProgressManager(totalCount: nil) + let child = overallReporter.subprogress(assigningCount: 5) + receiveProgress(progress: child) + XCTAssertNil(overallReporter.totalCount) + + let overallReporter2 = ProgressManager(totalCount: nil) + let interopChild = getProgressWithTotalCountInitialized() + overallReporter2.subprogress(assigningCount: 5, to: interopChild) + XCTAssertNil(overallReporter2.totalCount) + } + + func testInteropProgressParentProgressManagerChildConsistency() async throws { + let overallProgress = Progress() + let child = Progress(totalUnitCount: 5) + overallProgress.addChild(child, withPendingUnitCount: 5) + XCTAssertEqual(overallProgress.totalUnitCount, 0) + + let overallProgress2 = Progress() + let interopChild = overallProgress2.makeChild(withPendingUnitCount: 5) + receiveProgress(progress: interopChild) + XCTAssertEqual(overallProgress2.totalUnitCount, 0) + } + + func testIndirectParticipationOfProgressInAcyclicGraph() async throws { + 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 + XCTAssertEqual(progress.fractionCompleted, 0.5) + XCTAssertEqual(manager.fractionCompleted, 0.25) + XCTAssertEqual(parentManager1.fractionCompleted, 0.25) + XCTAssertEqual(parentManager2.fractionCompleted, 0.25) + +// progress.addChild(parentManager1.reporter, withPendingUnitCount: 1) // this should trigger cycle detection + } +} +#endif + +class TestProgressReporter: XCTestCase { + func testObserveProgressReporter() { + let manager = ProgressManager(totalCount: 3) + + let reporter = manager.reporter + + manager.complete(count: 1) + XCTAssertEqual(reporter.completedCount, 1) + + manager.complete(count: 1) + XCTAssertEqual(reporter.completedCount, 2) + + manager.complete(count: 1) + XCTAssertEqual(reporter.completedCount, 3) + + let fileCount = reporter.withProperties { properties in + properties.totalFileCount + } + XCTAssertEqual(fileCount, 0) + + manager.withProperties { properties in + properties.totalFileCount = 6 + } + XCTAssertEqual(reporter.withProperties(\.totalFileCount), 6) + + let totalFileCount = manager.total(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalFileCount, 6) + + let totalFileCountValues = manager.values(of: ProgressManager.Properties.TotalFileCount.self) + XCTAssertEqual(totalFileCountValues, [6]) + } + + 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) + XCTAssertEqual(altManager1.fractionCompleted, 0.125) + XCTAssertEqual(altManager2.fractionCompleted, 0.2) + + manager.complete(count: 1) + XCTAssertEqual(altManager1.fractionCompleted, 0.25) + XCTAssertEqual(altManager2.fractionCompleted, 0.4) + } + + 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 + } + XCTAssertEqual(overall.completedCount, 20) + XCTAssertEqual(overall.fractionCompleted, 25 / 30) + + child1.complete(count: 5) + + XCTAssertEqual(overall.completedCount, 30) + XCTAssertEqual(overall.fractionCompleted, 1.0) + } + + 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)) + + XCTAssertEqual(reporter1.fractionCompleted, 0.5) + + XCTAssertEqual(reporter2.fractionCompleted, 0.5) + + overall.withProperties { properties in + properties.totalCount = 30 + } + + XCTAssertEqual(overall.totalCount, 30) + XCTAssertEqual(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 + } + + /// All of these test cases hit the precondition for cycle detection, but currently there's no way to check for hitting precondition in xctest. +// func testProgressReporterDirectCycleDetection() { +// let manager = ProgressManager(totalCount: 2) +// +// manager.assign(count: 1, to: manager.reporter) +// } +// +// func testProgressReporterIndirectCycleDetection() { +// let manager = ProgressManager(totalCount: 2) +// +// let altManager = ProgressManager(totalCount: 1) +// altManager.assign(count: 1, to: manager.reporter) +// +// manager.assign(count: 1, to: altManager.reporter) +// } +// +// func testProgressReporterNestedCycleDetection() { +// 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) +// } +}