From 021e9935aaddb1aa4af6d459bad3a8ef541273fb Mon Sep 17 00:00:00 2001 From: David Richardson Date: Mon, 2 Nov 2026 15:29:21 -0600 Subject: [PATCH] Foundation: Add CPProgress and cancellable item provider loading Partial implementation, gaps and impossibilities noted internally as TODO comments. * CPProgress.j: Introduce CPProgress, modelled on NSProgress. - Count units of work, with fraction completed, finished and indeterminate states. - Compose progress trees through explicit children and the current progress object. - Cancel, pause and resume, with handlers, propagating to children. - Announce unit-count changes through key-value observing across the tree. * CPError.j: Add CPUserCancelledError. * CPItemProvider.j: - Return a CPProgress from each load, counting bytes read for files and aborting the read on cancellation. - Pass file contents to CPData as a binary string rather than a byte array. - Implement -canLoadObjectOfClass: and -loadObjectOfClass:completionHandler:. - Add the CPString (CPItemProvider) reading category. * CPString+Encoding.j: Decode data held as a binary string through a Uint8Array, without building a byte array. * Foundation.j: Import CPProgress.j. * CPProgressTest.j: Add unit tests covering counts, children, the current progress object, cancellation, pausing, observation and user information. * CPItemProviderTest.j: Add unit tests covering read progress, cancellation, completion and string loading. --- Foundation/CPError.j | 2 + Foundation/CPItemProvider.j | 250 ++++++++-- Foundation/CPProgress.j | 683 ++++++++++++++++++++++++++ Foundation/CPString+Encoding.j | 47 +- Foundation/Foundation.j | 1 + Tests/Foundation/CPItemProviderTest.j | 91 ++++ Tests/Foundation/CPProgressTest.j | 235 +++++++++ 7 files changed, 1275 insertions(+), 34 deletions(-) create mode 100644 Foundation/CPProgress.j create mode 100644 Tests/Foundation/CPProgressTest.j diff --git a/Foundation/CPError.j b/Foundation/CPError.j index 8e120ca9d..d4fcb1cc2 100644 --- a/Foundation/CPError.j +++ b/Foundation/CPError.j @@ -40,6 +40,8 @@ CPStringEncodingErrorKey = @"CPStringEncodingErrorKey"; CPURLErrorKey = kCFErrorURLKey; CPFilePathErrorKey = kCFErrorFilePathKey; +CPUserCancelledError = 3072; + /*! @class CPError @ingroup foundation diff --git a/Foundation/CPItemProvider.j b/Foundation/CPItemProvider.j index 70bde3f43..db56e70e2 100644 --- a/Foundation/CPItemProvider.j +++ b/Foundation/CPItemProvider.j @@ -25,8 +25,10 @@ @import "CPData.j" @import "CPDictionary.j" @import "CPError.j" +@import "CPProgress.j" @import "CPRunLoop.j" @import "CPString.j" +@import "CPString+Encoding.j" @global FileReader @@ -36,18 +38,35 @@ CPItemProviderUnknownError = -1; CPItemProviderItemUnavailableError = -1000; CPItemProviderUnexpectedValueClassError = -1100; +var CPItemProviderChunkSize = 32768; + +/* + Returns a binary string for an array buffer: one character for each byte, of the same value. + The buffer is converted in chunks, because a very long argument list exceeds the JavaScript + engine's limit. + + CPData holds a binary string compactly, one byte for each character in current JavaScript + engines. Its byte array would hold one JavaScript number for each byte, many times the size, + because CFData stores bytes that way. File contents are given to CPData as a binary string. +*/ +var CPItemProviderBinaryStringFromArrayBuffer = function(anArrayBuffer) +{ + var bytes = new Uint8Array(anArrayBuffer), + chunks = []; + + for (var i = 0, count = bytes.length; i < count; i += CPItemProviderChunkSize) + chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CPItemProviderChunkSize))); + + return chunks.join(""); +}; + /* TODO: Gaps against NSItemProvider that can be filled: - - -loadDataRepresentationForTypeIdentifier:completionHandler: returns an NSProgress in Cocoa, - which reports reading progress and cancels the load. Foundation has no CPProgress yet. - Cancelling a file read would call FileReader's abort(). - - Object loading: -canLoadObjectOfClass:, -loadObjectOfClass:completionHandler:, - -registerObject:visibility:, -initWithObject: and -initWithItem:typeIdentifier:, with the - reading and writing protocols. Loading a CPString this way could decode the browser's - buffer directly with TextDecoder. - - Files are read into a plain JavaScript array with one number per byte, because CFData - stores bytes that way. A large file needs many times its own size in memory. + - Writing: -registerObject:visibility:, -registerObjectOfClass:visibility:loadHandler:, + -initWithObject: and -initWithItem:typeIdentifier:, with a writing protocol. + - Loading a large file still holds it several times over at its peak: the browser's + buffer, the binary string in CPData, and the decoded text. */ /*! @@ -73,15 +92,21 @@ CPItemProviderUnexpectedValueClassError = -1100; - Representations have no visibility. A page is one process, so Cocoa's visibility levels have no meaning. + Each load returns a CPProgress. For a file, it counts the bytes read. Cancelling it stops + the read, and the completion handler then receives a CPUserCancelledError. + A completion handler receives the data, or nil and an error:
-    [anItemProvider loadDataRepresentationForTypeIdentifier:@"image/*" completionHandler:function(data, error)
+    var progress = [anItemProvider loadDataRepresentationForTypeIdentifier:@"image/*" completionHandler:function(data, error)
     {
         if (data)
             [self useData:data];
     }];
     
+ + An object of a class that adopts the item provider reading methods, such as CPString, can + be loaded with -loadObjectOfClass:completionHandler:. */ @implementation CPItemProvider : CPObject { @@ -128,7 +153,7 @@ CPItemProviderUnexpectedValueClassError = -1100; [itemProvider setSuggestedName:aFile.webkitRelativePath || aFile.name]; [itemProvider registerDataRepresentationForTypeIdentifier:typeIdentifier loadHandler:function(completionHandler) { - [CPItemProvider _readFile:aFile completionHandler:completionHandler]; + return [CPItemProvider _readFile:aFile completionHandler:completionHandler]; }]; return itemProvider; @@ -151,22 +176,37 @@ CPItemProviderUnexpectedValueClassError = -1100; /*! Reads a browser File object with FileReader, and calls \c aCompletionHandler with its - contents as data, or with nil and an error. + contents as data, or with nil and an error. Returns a progress object that counts the bytes + read. Cancelling it aborts the read. @ignore */ -+ (void)_readFile:(File)aFile completionHandler:(Function)aCompletionHandler ++ (CPProgress)_readFile:(File)aFile completionHandler:(Function)aCompletionHandler { + var progress = [CPProgress discreteProgressWithTotalUnitCount:MAX(aFile.size || 0, 1)]; + + [progress setKind:CPProgressKindFile]; + [progress setFileOperationKind:CPProgressFileOperationKindReceiving]; + if (typeof FileReader === "undefined") { aCompletionHandler(nil, [self _errorWithCode:CPItemProviderItemUnavailableError description:@"Files cannot be read in this environment."]); - return; + return progress; } var reader = new FileReader(); + reader.onprogress = function(anEvent) + { + if (anEvent.lengthComputable) + [progress setCompletedUnitCount:MIN(anEvent.loaded, [progress totalUnitCount] - 1)]; + }; + reader.onload = function() { - aCompletionHandler([CPData dataWithBytes:Array.from(new Uint8Array(reader.result))], nil); + var data = [CPData dataWithRawString:CPItemProviderBinaryStringFromArrayBuffer(reader.result)]; + + [progress setCompletedUnitCount:[progress totalUnitCount]]; + aCompletionHandler(data, nil); }; reader.onerror = function() @@ -179,7 +219,20 @@ CPItemProviderUnexpectedValueClassError = -1100; aCompletionHandler(nil, [self _errorWithCode:CPItemProviderUnknownError description:description]); }; + reader.onabort = function() + { + aCompletionHandler(nil, [self _cancelledError]); + }; + + [progress setCancellationHandler:function() + { + if (reader.readyState === 1) + reader.abort(); + }]; + reader.readAsArrayBuffer(aFile); + + return progress; } /*! @@ -192,6 +245,16 @@ CPItemProviderUnexpectedValueClassError = -1100; userInfo:@{ CPLocalizedDescriptionKey: aDescription }]; } +/*! + @ignore +*/ ++ (CPError)_cancelledError +{ + return [CPError errorWithDomain:CPCocoaErrorDomain + code:CPUserCancelledError + userInfo:@{ CPLocalizedDescriptionKey: @"The operation was cancelled." }]; +} + /*! Initializes an item provider with no representations. */ @@ -215,10 +278,12 @@ CPItemProviderUnexpectedValueClassError = -1100; The load handler is called with one argument, a completion handler. It must call that completion handler once, with the data, or with nil and an error. It may do so at once, or - later. + later. It may return a CPProgress for its work; the progress object returned by + -loadDataRepresentationForTypeIdentifier:completionHandler: then follows it, and cancelling + that progress object cancels it. @param aTypeIdentifier a MIME type, such as "text/csv" - @param aLoadHandler a function that takes a completion handler + @param aLoadHandler a function that takes a completion handler, and may return a CPProgress */ - (void)registerDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier loadHandler:(Function /*(Function completionHandler)*/)aLoadHandler { @@ -276,14 +341,17 @@ CPItemProviderUnexpectedValueClassError = -1100; /*! Loads the first registered representation that matches \c aTypeIdentifier, which may be a wildcard of the form "image/*". The completion handler is called once, inside a run loop - pass, with the data and nil, or with nil and an error. + pass, with the data and nil, or with nil and an error. When the returned progress object is + cancelled first, the error is a CPUserCancelledError. @param aTypeIdentifier the type identifier to load @param aCompletionHandler a function that takes the data and an error + @return a progress object for the load */ -- (void)loadDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier completionHandler:(Function /*(CPData data, CPError error)*/)aCompletionHandler +- (CPProgress)loadDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier completionHandler:(Function /*(CPData data, CPError error)*/)aCompletionHandler { var registeredTypeIdentifier = [self _registeredTypeIdentifierConformingToTypeIdentifier:aTypeIdentifier], + progress = [CPProgress discreteProgressWithTotalUnitCount:1], didComplete = NO, complete = function(data, error) { @@ -292,8 +360,19 @@ CPItemProviderUnexpectedValueClassError = -1100; didComplete = YES; + if ([progress isCancelled]) + { + data = nil; + error = [CPItemProvider _cancelledError]; + } + else if (!data && !error) + error = [CPItemProvider _errorWithCode:CPItemProviderUnknownError description:@"The item could not be loaded."]; + + if (data && ![progress isFinished]) + [progress setCompletedUnitCount:1]; + if (aCompletionHandler) - aCompletionHandler(data || nil, data ? nil : error || [CPItemProvider _errorWithCode:CPItemProviderUnknownError description:@"The item could not be loaded."]); + aCompletionHandler(data || nil, data ? nil : error); [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; }; @@ -301,21 +380,94 @@ CPItemProviderUnexpectedValueClassError = -1100; if (!registeredTypeIdentifier) { complete(nil, [CPItemProvider _errorWithCode:CPItemProviderItemUnavailableError description:@"The item has no representation of type " + aTypeIdentifier + @"."]); - return; + return progress; } - var loadHandler = [_loadHandlers objectForKey:registeredTypeIdentifier]; - - loadHandler(function(data, error) - { - if (data && ![data isKindOfClass:[CPData class]]) + var loadHandler = [_loadHandlers objectForKey:registeredTypeIdentifier], + loadProgress = loadHandler(function(data, error) { - complete(nil, [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item's load handler returned an object that is not data."]); + if (data && ![data isKindOfClass:[CPData class]]) + { + complete(nil, [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item's load handler returned an object that is not data."]); + return; + } + + complete(data, error); + }); + + if (loadProgress && [loadProgress isKindOfClass:[CPProgress class]] && !didComplete) + [progress addChild:loadProgress withPendingUnitCount:1]; + + return progress; +} + +// MARK: Loading Objects + +/*! + Returns YES if an object of \c aClass can be loaded from the item. \c aClass must implement + +readableTypeIdentifiersForItemProvider, and the item must have a representation of one of + those types. +*/ +- (BOOL)canLoadObjectOfClass:(Class)aClass +{ + return [self _typeIdentifierReadableByClass:aClass] !== nil; +} + +/*! + Loads an object of \c aClass from the item. The first representation of a type that the + class can read is loaded, and given to +objectWithItemProviderData:typeIdentifier:error:. + The completion handler is called once, inside a run loop pass, with the object and nil, or + with nil and an error. + + @param aClass a class that implements the item provider reading methods, such as CPString + @param aCompletionHandler a function that takes the object and an error + @return a progress object for the load +*/ +- (CPProgress)loadObjectOfClass:(Class)aClass completionHandler:(Function /*(id object, CPError error)*/)aCompletionHandler +{ + var typeIdentifier = [self _typeIdentifierReadableByClass:aClass]; + + if (!typeIdentifier) + return [self loadDataRepresentationForTypeIdentifier:@"" completionHandler:function(data, error) + { + aCompletionHandler(nil, [CPItemProvider _errorWithCode:CPItemProviderItemUnavailableError description:@"The item has no representation that " + [aClass className] + @" can read."]); + }]; + + return [self loadDataRepresentationForTypeIdentifier:typeIdentifier completionHandler:function(data, error) + { + if (!data) + { + aCompletionHandler(nil, error); return; } - complete(data, error); - }); + var readError = nil, + object = [aClass objectWithItemProviderData:data typeIdentifier:typeIdentifier error:@ref(readError)]; + + if (object === nil || object === undefined) + aCompletionHandler(nil, readError || [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item could not be read as " + [aClass className] + @"."]); + else + aCompletionHandler(object, nil); + }]; +} + +/*! + Returns the first registered type identifier that \c aClass can read, or nil. + @ignore +*/ +- (CPString)_typeIdentifierReadableByClass:(Class)aClass +{ + if (![aClass respondsToSelector:@selector(readableTypeIdentifiersForItemProvider)]) + return nil; + + var readableTypeIdentifiers = [aClass readableTypeIdentifiersForItemProvider]; + + for (var i = 0, count = [_registeredTypeIdentifiers count]; i < count; i++) + for (var j = 0, readableCount = [readableTypeIdentifiers count]; j < readableCount; j++) + if ([CPItemProvider _typeIdentifier:_registeredTypeIdentifiers[i] conformsToTypeIdentifier:readableTypeIdentifiers[j]]) + return _registeredTypeIdentifiers[i]; + + return nil; } /*! @@ -327,3 +479,43 @@ CPItemProviderUnexpectedValueClassError = -1100; } @end + +/*! + CPString reads text from an item provider. The encoding is detected, as by + +stringEncodingForData:encodingOptions:convertedString:usedLossyConversion:. + + Browsers report some text formats under other MIME types; for example, a CSV file may be + reported as "application/vnd.ms-excel" on Windows. Such an item is not readable as a string + by this method, but its data can be loaded and decoded directly. +*/ +@implementation CPString (CPItemProvider) + +/*! + Returns the MIME types a string can be read from: every text type, JSON, XML and JavaScript. +*/ ++ (CPArray)readableTypeIdentifiersForItemProvider +{ + return [@"text/*", @"application/json", @"application/xml", @"application/javascript"]; +} + +/*! + Returns a string decoded from an item provider's data, or nil with an error when no + encoding fits the data. +*/ ++ (id)objectWithItemProviderData:(CPData)someData typeIdentifier:(CPString)aTypeIdentifier error:(CPErrorRef)anErrorRef +{ + var string = nil, + encoding = [CPString stringEncodingForData:someData encodingOptions:nil convertedString:@ref(string) usedLossyConversion:nil]; + + if (encoding) + return string; + + if (anErrorRef) + @deref(anErrorRef) = [CPError errorWithDomain:CPItemProviderErrorDomain + code:CPItemProviderUnexpectedValueClassError + userInfo:@{ CPLocalizedDescriptionKey: @"The item's data is not text in a known encoding." }]; + + return nil; +} + +@end diff --git a/Foundation/CPProgress.j b/Foundation/CPProgress.j new file mode 100644 index 000000000..b4d27ca7f --- /dev/null +++ b/Foundation/CPProgress.j @@ -0,0 +1,683 @@ +/* + * CPProgress.j + * Foundation + * + * Created by David Richardson on 2026-11-02. + * Copyright David Richardson 2026. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +@import "CPArray.j" +@import "CPDictionary.j" +@import "CPException.j" +@import "CPKeyValueObserving.j" +@import "CPObject.j" +@import "CPSet.j" +@import "CPString.j" + +CPProgressKindFile = @"CPProgressKindFile"; + +CPProgressEstimatedTimeRemainingKey = @"CPProgressEstimatedTimeRemainingKey"; +CPProgressThroughputKey = @"CPProgressThroughputKey"; +CPProgressFileOperationKindKey = @"CPProgressFileOperationKindKey"; +CPProgressFileURLKey = @"CPProgressFileURLKey"; +CPProgressFileTotalCountKey = @"CPProgressFileTotalCountKey"; +CPProgressFileCompletedCountKey = @"CPProgressFileCompletedCountKey"; + +CPProgressFileOperationKindDownloading = @"CPProgressFileOperationKindDownloading"; +CPProgressFileOperationKindDecompressingAfterDownloading = @"CPProgressFileOperationKindDecompressingAfterDownloading"; +CPProgressFileOperationKindReceiving = @"CPProgressFileOperationKindReceiving"; +CPProgressFileOperationKindCopying = @"CPProgressFileOperationKindCopying"; +CPProgressFileOperationKindUploading = @"CPProgressFileOperationKindUploading"; +CPProgressFileOperationKindDuplicating = @"CPProgressFileOperationKindDuplicating"; + +/* + The keys whose values follow from the unit counts of a progress object and of its children. + They change together, so they are announced together. See -_performCountChange:. +*/ +var CPProgressCountKeys = [@"totalUnitCount", @"completedUnitCount", @"fractionCompleted", @"finished", @"indeterminate", @"localizedDescription", @"localizedAdditionalDescription"], + CPProgressManuallyNotifiedKeys = [CPSet setWithArray:CPProgressCountKeys.concat([@"cancelled", @"paused", @"cancellable", @"pausable", @"kind", @"userInfo"])], + CPProgressCurrentStack = []; + +/* + TODO: Gaps against NSProgress that can be filled: + + - The fileURL, fileTotalCount and fileCompletedCount properties. Only their user + information keys exist. + - The NSProgressReporting protocol, with its progress property. + - The default descriptions. Cocoa's depend on the kind, and its additional description + includes the estimated time remaining and the throughput. These are the percentage and + the unit counts only. + - The file icon and file animation user information keys. + - Setting a cancellation or pausing handler on a progress object that is already cancelled + or paused. Check whether Cocoa calls the handler at once; this class does not. + - Key-value observing. Cocoa announces only the keys that change; this class announces + every unit-count key of each progress object in the chain. +*/ + +/*! + @class CPProgress + @ingroup foundation + @brief Reports the progress of a task, and lets the user cancel or pause it. + + This class is modelled on NSProgress. A progress object counts units of work: a total, and + the units completed. Its fraction completed can be observed with key-value observing, and + shown in a progress indicator. + + Progress objects form a tree. A child progress object stands for a number of its parent's + units, its pending unit count. As the child advances, the parent's fraction completed + advances in proportion. When the child finishes, its pending units are added to the + parent's completed units. A child is attached explicitly, with + -addChild:withPendingUnitCount:, or implicitly: a progress object made while its parent is + the current progress object, between -becomeCurrentWithPendingUnitCount: and + -resignCurrent, becomes its child. + + Cancelling or pausing a progress object also cancels or pauses its children, and calls its + handler. A task checks -isCancelled, or sets a cancellation handler, to stop its work. + + In a browser there is one thread, so handlers are called at once, on that thread. The + unit-count keys are announced together whenever a count changes in the tree, so an + observer may receive a notification for a key whose value did not change. + + Publishing and subscribing (-publish, -unpublish, the subscriber methods and -isOld) are + not provided. They share file progress between processes, and a page is one process. +*/ +@implementation CPProgress : CPObject +{ + CPProgress _parent; + CPArray _children; + + int _totalUnitCount; + int _completedUnitCount; + + BOOL _cancellable; + BOOL _pausable; + BOOL _cancelled; + BOOL _paused; + + Function _cancellationHandler @accessors(property=cancellationHandler); + Function _pausingHandler @accessors(property=pausingHandler); + Function _resumingHandler @accessors(property=resumingHandler); + + CPString _localizedDescription; + CPString _localizedAdditionalDescription; + CPString _kind; + CPMutableDictionary _userInfo; +} + ++ (BOOL)automaticallyNotifiesObserversForKey:(CPString)aKey +{ + if ([CPProgressManuallyNotifiedKeys containsObject:aKey]) + return NO; + + return [super automaticallyNotifiesObserversForKey:aKey]; +} + +// MARK: Creating Progress Objects + +/*! + Returns the progress object that is current, or nil. See -becomeCurrentWithPendingUnitCount:. +*/ ++ (CPProgress)currentProgress +{ + var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1]; + + return entry ? entry.progress : nil; +} + +/*! + Returns a progress object with a total unit count. If a progress object is current, the new + one becomes its child. +*/ ++ (CPProgress)progressWithTotalUnitCount:(int)aCount +{ + var progress = [[self alloc] initWithParent:[self currentProgress] userInfo:nil]; + + [progress setTotalUnitCount:aCount]; + + return progress; +} + +/*! + Returns a progress object with a total unit count and no parent, even if a progress object + is current. +*/ ++ (CPProgress)discreteProgressWithTotalUnitCount:(int)aCount +{ + var progress = [[self alloc] initWithParent:nil userInfo:nil]; + + [progress setTotalUnitCount:aCount]; + + return progress; +} + +/*! + Returns a progress object with a total unit count, attached to \c aParent as a child that + stands for \c aPendingUnitCount of its units. +*/ ++ (CPProgress)progressWithTotalUnitCount:(int)aCount parent:(CPProgress)aParent pendingUnitCount:(int)aPendingUnitCount +{ + var progress = [self discreteProgressWithTotalUnitCount:aCount]; + + [aParent addChild:progress withPendingUnitCount:aPendingUnitCount]; + + return progress; +} + +/*! + Initializes a progress object. If a progress object is current, the new one becomes its + child. +*/ +- (id)init +{ + return [self initWithParent:[CPProgress currentProgress] userInfo:nil]; +} + +/*! + Initializes a progress object. \c aParent must be nil or the current progress object; the + new progress object becomes its child, standing for the pending unit count given to + -becomeCurrentWithPendingUnitCount:. Only the first progress object made while a parent is + current becomes its child. + + @throws CPInvalidArgumentException if \c aParent is not nil and not current +*/ +- (id)initWithParent:(CPProgress)aParent userInfo:(CPDictionary)aUserInfo +{ + if (self = [super init]) + { + _children = []; + _totalUnitCount = 0; + _completedUnitCount = 0; + _cancellable = YES; + _pausable = NO; + _cancelled = NO; + _paused = NO; + _userInfo = aUserInfo ? [aUserInfo mutableCopy] : [CPMutableDictionary dictionary]; + + if (aParent) + { + var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1]; + + if (!entry || entry.progress !== aParent) + [CPException raise:CPInvalidArgumentException reason:@"The parent of a progress object must be nil or the current progress object."]; + + if (!entry.hasAttachedChild) + { + entry.hasAttachedChild = YES; + [aParent addChild:self withPendingUnitCount:entry.pendingUnitCount]; + } + } + } + + return self; +} + +// MARK: The Current Progress Object + +/*! + Makes the receiver the current progress object. The first progress object made before + -resignCurrent becomes its child, standing for \c aPendingUnitCount of its units. +*/ +- (void)becomeCurrentWithPendingUnitCount:(int)aPendingUnitCount +{ + CPProgressCurrentStack.push({ progress: self, pendingUnitCount: aPendingUnitCount, hasAttachedChild: NO }); +} + +/*! + Ends the receiver's time as the current progress object. If no child was made meanwhile, + the pending units are completed. + + @throws CPInternalInconsistencyException if the receiver is not current +*/ +- (void)resignCurrent +{ + var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1]; + + if (!entry || entry.progress !== self) + [CPException raise:CPInternalInconsistencyException reason:@"-resignCurrent was sent to a progress object that is not current."]; + + CPProgressCurrentStack.pop(); + + if (!entry.hasAttachedChild) + [self setCompletedUnitCount:_completedUnitCount + entry.pendingUnitCount]; +} + +/*! + Makes the receiver current, calls \c aBlock, and resigns. +*/ +- (void)performAsCurrentWithPendingUnitCount:(int)aPendingUnitCount usingBlock:(Function)aBlock +{ + [self becomeCurrentWithPendingUnitCount:aPendingUnitCount]; + + try + { + aBlock(); + } + finally + { + [self resignCurrent]; + } +} + +// MARK: Children + +/*! + Attaches \c aChild, which stands for \c aPendingUnitCount of the receiver's units. A child + attached to a cancelled or paused progress object is cancelled or paused at once. + + @throws CPInvalidArgumentException if \c aChild already has a parent +*/ +- (void)addChild:(CPProgress)aChild withPendingUnitCount:(int)aPendingUnitCount +{ + if (aChild._parent) + [CPException raise:CPInvalidArgumentException reason:@"A progress object can have only one parent."]; + + [self _performCountChange:function() + { + aChild._parent = self; + _children.push({ progress: aChild, pendingUnitCount: aPendingUnitCount }); + [aChild _finishIfComplete]; + }]; + + if (_cancelled) + [aChild cancel]; + + if (_paused) + [aChild pause]; +} + +// MARK: Unit Counts + +/*! + Returns the total number of units of work. +*/ +- (int)totalUnitCount +{ + return _totalUnitCount; +} + +/*! + Sets the total number of units of work. A negative total makes the progress indeterminate. +*/ +- (void)setTotalUnitCount:(int)aCount +{ + if (aCount === _totalUnitCount) + return; + + [self _performCountChange:function() + { + _totalUnitCount = aCount; + [self _finishIfComplete]; + }]; +} + +/*! + Returns the number of units of work completed. +*/ +- (int)completedUnitCount +{ + return _completedUnitCount; +} + +/*! + Sets the number of units of work completed. When it reaches the total, a parent completes + the units this progress object stands for. +*/ +- (void)setCompletedUnitCount:(int)aCount +{ + if (aCount === _completedUnitCount) + return; + + [self _performCountChange:function() + { + _completedUnitCount = aCount; + [self _finishIfComplete]; + }]; +} + +/*! + Returns the fraction of the work completed, including the progress of children that have + not finished, from 0.0. It is 0.0 while the progress is indeterminate. +*/ +- (double)fractionCompleted +{ + if (_totalUnitCount <= 0) + return 0.0; + + var units = _completedUnitCount; + + for (var i = 0; i < _children.length; i++) + units += [_children[i].progress fractionCompleted] * _children[i].pendingUnitCount; + + return units / _totalUnitCount; +} + +/*! + Returns YES when the work is complete: the total is positive, and the completed units have + reached it. +*/ +- (BOOL)isFinished +{ + return _totalUnitCount > 0 && _completedUnitCount >= _totalUnitCount; +} + +/*! + Returns YES when the amount of work is unknown: either count is negative, or both are 0. +*/ +- (BOOL)isIndeterminate +{ + return _totalUnitCount < 0 || _completedUnitCount < 0 || (_totalUnitCount === 0 && _completedUnitCount === 0); +} + +/* + Changes unit counts in the tree. Every count key of the receiver and of each of its + ancestors is announced before and after the change, so that an observer of any of them sees + a consistent value. A parent's counts and fraction depend on its children, so a change + anywhere below reaches every ancestor. +*/ +- (void)_performCountChange:(Function)aChange +{ + var chain = []; + + for (var progress = self; progress; progress = progress._parent) + chain.push(progress); + + for (var i = 0; i < chain.length; i++) + for (var j = 0; j < CPProgressCountKeys.length; j++) + [chain[i] willChangeValueForKey:CPProgressCountKeys[j]]; + + aChange(); + + for (var i = chain.length - 1; i >= 0; i--) + for (var j = CPProgressCountKeys.length - 1; j >= 0; j--) + [chain[i] didChangeValueForKey:CPProgressCountKeys[j]]; +} + +/* + When the receiver has finished, its parent removes it and completes the units it stood for. + That may finish the parent in turn. Called inside -_performCountChange:, which has already + announced the change for every ancestor. +*/ +- (void)_finishIfComplete +{ + var parent = _parent; + + if (![self isFinished] || !parent) + return; + + for (var i = 0; i < parent._children.length; i++) + { + var entry = parent._children[i]; + + if (entry.progress !== self) + continue; + + parent._children.splice(i, 1); + parent._completedUnitCount += entry.pendingUnitCount; + _parent = nil; + + [parent _finishIfComplete]; + return; + } +} + +// MARK: Cancelling and Pausing + +/*! + Returns YES if the user may cancel the work. This is a hint for a user interface: -cancel + works either way. The default is YES. +*/ +- (BOOL)isCancellable +{ + return _cancellable; +} + +- (void)setCancellable:(BOOL)isCancellable +{ + [self _setValue:!!isCancellable forIvar:@"_cancellable" key:@"cancellable"]; +} + +/*! + Returns YES if the user may pause the work. This is a hint for a user interface. The + default is NO. +*/ +- (BOOL)isPausable +{ + return _pausable; +} + +- (void)setPausable:(BOOL)isPausable +{ + [self _setValue:!!isPausable forIvar:@"_pausable" key:@"pausable"]; +} + +/*! + Returns YES once the work has been cancelled. +*/ +- (BOOL)isCancelled +{ + return _cancelled; +} + +/*! + Returns YES while the work is paused. +*/ +- (BOOL)isPaused +{ + return _paused; +} + +/*! + Cancels the work: marks the receiver cancelled, calls its cancellation handler, and cancels + its children. Cancelling again does nothing. +*/ +- (void)cancel +{ + if (_cancelled) + return; + + [self _setValue:YES forIvar:@"_cancelled" key:@"cancelled"]; + + if (_cancellationHandler) + _cancellationHandler(); + + var children = _children.slice(); + + for (var i = 0; i < children.length; i++) + [children[i].progress cancel]; +} + +/*! + Pauses the work: marks the receiver paused, calls its pausing handler, and pauses its + children. +*/ +- (void)pause +{ + if (_paused) + return; + + [self _setValue:YES forIvar:@"_paused" key:@"paused"]; + + if (_pausingHandler) + _pausingHandler(); + + var children = _children.slice(); + + for (var i = 0; i < children.length; i++) + [children[i].progress pause]; +} + +/*! + Resumes paused work: marks the receiver not paused, calls its resuming handler, and resumes + its children. +*/ +- (void)resume +{ + if (!_paused) + return; + + [self _setValue:NO forIvar:@"_paused" key:@"paused"]; + + if (_resumingHandler) + _resumingHandler(); + + var children = _children.slice(); + + for (var i = 0; i < children.length; i++) + [children[i].progress resume]; +} + +// MARK: Descriptions + +/*! + Returns a description of the progress. Unless one has been set, it is the percentage + completed, such as "42% completed". +*/ +- (CPString)localizedDescription +{ + if (_localizedDescription !== nil && _localizedDescription !== undefined) + return _localizedDescription; + + return ROUND([self fractionCompleted] * 100) + @"% completed"; +} + +- (void)setLocalizedDescription:(CPString)aDescription +{ + [self _setValue:aDescription forIvar:@"_localizedDescription" key:@"localizedDescription"]; +} + +/*! + Returns more detail about the progress. Unless one has been set, it is the units completed + and the total, such as "42 of 100". +*/ +- (CPString)localizedAdditionalDescription +{ + if (_localizedAdditionalDescription !== nil && _localizedAdditionalDescription !== undefined) + return _localizedAdditionalDescription; + + if ([self isIndeterminate]) + return @""; + + return _completedUnitCount + @" of " + _totalUnitCount; +} + +- (void)setLocalizedAdditionalDescription:(CPString)aDescription +{ + [self _setValue:aDescription forIvar:@"_localizedAdditionalDescription" key:@"localizedAdditionalDescription"]; +} + +/*! + Returns the kind of progress, such as CPProgressKindFile, or nil. +*/ +- (CPString)kind +{ + return _kind; +} + +- (void)setKind:(CPString)aKind +{ + [self _setValue:aKind forIvar:@"_kind" key:@"kind"]; +} + +// MARK: User Information + +/*! + Returns a copy of the receiver's user information. +*/ +- (CPDictionary)userInfo +{ + return [_userInfo copy]; +} + +/*! + Sets a value in the receiver's user information, or removes it when \c anObject is nil. +*/ +- (void)setUserInfoObject:(id)anObject forKey:(CPString)aKey +{ + [self willChangeValueForKey:@"userInfo"]; + + if (anObject === nil || anObject === undefined) + [_userInfo removeObjectForKey:aKey]; + else + [_userInfo setObject:anObject forKey:aKey]; + + [self didChangeValueForKey:@"userInfo"]; +} + +/*! + Returns the estimated time remaining, in seconds, or nil. It is stored in the user + information under CPProgressEstimatedTimeRemainingKey. +*/ +- (CPNumber)estimatedTimeRemaining +{ + return [_userInfo objectForKey:CPProgressEstimatedTimeRemainingKey]; +} + +- (void)setEstimatedTimeRemaining:(CPNumber)aTime +{ + [self setUserInfoObject:aTime forKey:CPProgressEstimatedTimeRemainingKey]; +} + +/*! + Returns the throughput, in units per second, or nil. It is stored in the user information + under CPProgressThroughputKey. +*/ +- (CPNumber)throughput +{ + return [_userInfo objectForKey:CPProgressThroughputKey]; +} + +- (void)setThroughput:(CPNumber)aThroughput +{ + [self setUserInfoObject:aThroughput forKey:CPProgressThroughputKey]; +} + +/*! + Returns the kind of file operation, such as CPProgressFileOperationKindDownloading, or nil. + It is stored in the user information under CPProgressFileOperationKindKey. +*/ +- (CPString)fileOperationKind +{ + return [_userInfo objectForKey:CPProgressFileOperationKindKey]; +} + +- (void)setFileOperationKind:(CPString)aKind +{ + [self setUserInfoObject:aKind forKey:CPProgressFileOperationKindKey]; +} + +/* + Sets an instance variable and announces the change of its key, when the value changes. +*/ +- (void)_setValue:(id)aValue forIvar:(CPString)anIvarName key:(CPString)aKey +{ + if (self[anIvarName] === aValue) + return; + + [self willChangeValueForKey:aKey]; + self[anIvarName] = aValue; + [self didChangeValueForKey:aKey]; +} + +/*! + Returns a description of the progress object: its counts and fraction completed. +*/ +- (CPString)description +{ + return [super description] + @" " + _completedUnitCount + @"/" + _totalUnitCount + @" (" + [self fractionCompleted] + @")" + (_cancelled ? @" cancelled" : @"") + (_paused ? @" paused" : @""); +} + +@end diff --git a/Foundation/CPString+Encoding.j b/Foundation/CPString+Encoding.j index c462f244b..4e55c5987 100644 --- a/Foundation/CPString+Encoding.j +++ b/Foundation/CPString+Encoding.j @@ -117,7 +117,7 @@ var CPStringEncodingDecodeWithTextDecoder = function(aLabel, bytes, isLossy) try { - return new TextDecoder(aLabel, { fatal: !isLossy }).decode(new Uint8Array(bytes)); + return new TextDecoder(aLabel, { fatal: !isLossy }).decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)); } catch (anException) { @@ -142,7 +142,7 @@ var CPStringEncodingDecodeASCII = function(bytes, isLossy) if (!isLossy) return nil; - bytes = bytes.map(function(aByte) { return aByte > 0x7F ? CPStringEncodingReplacementCharacter : aByte; }); + bytes = Array.prototype.map.call(bytes, function(aByte) { return aByte > 0x7F ? CPStringEncodingReplacementCharacter : aByte; }); break; } @@ -705,6 +705,38 @@ var CPStringEncodingIsAvailable = function(aDescriptor) } }; +/* + Returns the bytes of data as an array of bytes. Data that holds a binary string, such as + file contents from CPItemProvider, is converted to a Uint8Array, which takes one byte for + each byte. Asking CPData for its bytes would build a plain array of JavaScript numbers, many + times the size, because CFData stores bytes that way, and keep it in the data. +*/ +var CPStringEncodingBytesOfData = function(someData) +{ + if (!someData) + return []; + + if (someData._bytes) + return someData._bytes; + + var string = someData.rawString(), + count = string.length, + bytes = new Uint8Array(count); + + for (var i = 0; i < count; i++) + bytes[i] = string.charCodeAt(i) & 0xFF; + + return bytes; +}; + +/* + Returns the bytes from anIndex to the end, without copying when the bytes are a Uint8Array. +*/ +var CPStringEncodingBytesFromIndex = function(bytes, anIndex) +{ + return bytes.subarray ? bytes.subarray(anIndex) : bytes.slice(anIndex); +}; + var CPStringEncodingDecode = function(bytes, anEncoding, isLossy) { var descriptor = CPStringEncodingDescriptors[anEncoding]; @@ -807,7 +839,12 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy) */ - (id)initWithData:(CPData)someData encoding:(CPStringEncoding)anEncoding { - return [self initWithBytes:someData ? [someData bytes] : [] length:someData ? [someData length] : 0 encoding:anEncoding]; + var decoded = CPStringEncodingDecode(CPStringEncodingBytesOfData(someData), anEncoding, NO); + + if (decoded === nil) + return nil; + + return [self initWithString:decoded]; } /*! @@ -856,7 +893,7 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy) */ + (CPStringEncoding)stringEncodingForData:(CPData)someData encodingOptions:(CPDictionary)someOptions convertedString:(CPStringRef)aStringRef usedLossyConversion:(BOOLRef)aLossyRef { - var bytes = someData ? [someData bytes] : [], + var bytes = CPStringEncodingBytesOfData(someData), suggested = [someOptions objectForKey:CPStringEncodingDetectionSuggestedEncodingsKey] || [], usesOnlySuggested = !![someOptions objectForKey:CPStringEncodingDetectionUseOnlySuggestedEncodingsKey], allowLossy = [someOptions objectForKey:CPStringEncodingDetectionAllowLossyKey], @@ -887,7 +924,7 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy) if (bytes.length >= mark.length && mark.every(function(aByte, anIndex) { return bytes[anIndex] === aByte; })) { - var decoded = CPStringEncodingDecode(bytes.slice(mark.length), byteOrderMarks[i][1], NO); + var decoded = CPStringEncodingDecode(CPStringEncodingBytesFromIndex(bytes, mark.length), byteOrderMarks[i][1], NO); if (decoded !== nil) return result(byteOrderMarks[i][1], decoded, NO); diff --git a/Foundation/Foundation.j b/Foundation/Foundation.j index c2bcae4bd..729e6351a 100755 --- a/Foundation/Foundation.j +++ b/Foundation/Foundation.j @@ -65,6 +65,7 @@ @import "CPOperation.j" @import "CPOperationQueue.j" @import "CPPredicate.j" +@import "CPProgress.j" @import "CPPropertyListSerialization.j" @import "CPRange.j" @import "CPRunLoop.j" diff --git a/Tests/Foundation/CPItemProviderTest.j b/Tests/Foundation/CPItemProviderTest.j index 07611fdc6..cb71f1544 100644 --- a/Tests/Foundation/CPItemProviderTest.j +++ b/Tests/Foundation/CPItemProviderTest.j @@ -216,4 +216,95 @@ [self assert:@"a.png" equals:[providers[1] suggestedName]]; } +/* + Installs a FileReader stand-in that reads nothing until the test drives it, and returns an + object through which the test reaches the reader. +*/ +- (JSObject)installPendingFileReader +{ + var state = { reader: nil, aborted: NO }; + + globalThis.FileReader = function() { state.reader = this; this.readyState = 0; }; + globalThis.FileReader.prototype.readAsArrayBuffer = function(aFile) { this.readyState = 1; }; + globalThis.FileReader.prototype.abort = function() + { + state.aborted = YES; + this.readyState = 2; + this.onabort(); + }; + + return state; +} + +- (void)testFileProgressAndCancellation +{ + var state = [self installPendingFileReader], + provider = [CPItemProvider _itemProviderWithFile:{ name: "big.csv", type: "text/csv", size: 1000 }], + resultError = nil, + calls = 0, + progress = [provider loadDataRepresentationForTypeIdentifier:@"text/csv" completionHandler:function(someData, anError) + { + calls++; + resultError = anError; + }]; + + state.reader.onprogress({ lengthComputable: YES, loaded: 250 }); + + [self assert:0.25 equals:[progress fractionCompleted]]; + + [progress cancel]; + + [self assertTrue:state.aborted]; + [self assert:1 equals:calls]; + [self assert:CPCocoaErrorDomain equals:[resultError domain]]; + [self assert:CPUserCancelledError equals:[resultError code]]; +} + +- (void)testFileProgressFinishes +{ + var state = [self installPendingFileReader], + provider = [CPItemProvider _itemProviderWithFile:{ name: "small.csv", type: "text/csv", size: 2 }], + result = nil, + progress = [provider loadDataRepresentationForTypeIdentifier:@"text/csv" completionHandler:function(someData, anError) + { + result = someData; + }]; + + state.reader.result = new Uint8Array([104, 105]).buffer; + state.reader.onload(); + + [self assertTrue:[progress isFinished]]; + [self assert:@"hi" equals:[result rawString]]; +} + +- (void)testLoadObjectOfClassString +{ + var provider = [self providerWithType:@"text/csv" data:[CPData dataWithRawString:@"a,b"]], + result = nil; + + [self assertTrue:[provider canLoadObjectOfClass:[CPString class]]]; + + [provider loadObjectOfClass:[CPString class] completionHandler:function(anObject, anError) + { + result = anObject; + }]; + + [self assert:@"a,b" equals:result]; +} + +- (void)testCannotLoadStringFromImage +{ + var provider = [self providerWithType:@"image/png" data:[CPData dataWithRawString:@"x"]], + resultError = nil; + + [self assertFalse:[provider canLoadObjectOfClass:[CPString class]]]; + + [provider loadObjectOfClass:[CPString class] completionHandler:function(anObject, anError) + { + resultError = anError; + }]; + + [self assert:CPItemProviderItemUnavailableError equals:[resultError code]]; +} + @end diff --git a/Tests/Foundation/CPProgressTest.j b/Tests/Foundation/CPProgressTest.j new file mode 100644 index 000000000..8c1358a1a --- /dev/null +++ b/Tests/Foundation/CPProgressTest.j @@ -0,0 +1,235 @@ +/* + * CPProgressTest.j + * Foundation + * + * Created by David Richardson on 2026-11-02. + * Copyright David Richardson 2026. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +@import +@import + +@implementation CPProgressTestObserver : CPObject +{ + CPArray values; +} + +- (id)init +{ + if (self = [super init]) + values = []; + + return self; +} + +- (CPArray)values +{ + return values; +} + +- (void)observeValueForKeyPath:(CPString)aKeyPath ofObject:(id)anObject change:(CPDictionary)aChange context:(id)aContext +{ + values.push([aChange objectForKey:CPKeyValueChangeNewKey]); +} + +@end + +@implementation CPProgressTest : OJTestCase +{ +} + +- (void)testCounts +{ + var progress = [CPProgress discreteProgressWithTotalUnitCount:10]; + + [self assertTrue:[progress isCancellable]]; + [self assertFalse:[progress isPausable]]; + [self assert:0.0 equals:[progress fractionCompleted]]; + + [progress setCompletedUnitCount:4]; + + [self assert:0.4 equals:[progress fractionCompleted]]; + [self assertFalse:[progress isFinished]]; + [self assert:@"40% completed" equals:[progress localizedDescription]]; + [self assert:@"4 of 10" equals:[progress localizedAdditionalDescription]]; + + [progress setCompletedUnitCount:10]; + + [self assertTrue:[progress isFinished]]; +} + +- (void)testIndeterminate +{ + var progress = [CPProgress discreteProgressWithTotalUnitCount:0]; + + [self assertTrue:[progress isIndeterminate]]; + + [progress setTotalUnitCount:-1]; + [progress setCompletedUnitCount:3]; + + [self assertTrue:[progress isIndeterminate]]; + [self assert:0.0 equals:[progress fractionCompleted]]; + [self assertFalse:[progress isFinished]]; + + [progress setTotalUnitCount:6]; + + [self assertFalse:[progress isIndeterminate]]; + [self assert:0.5 equals:[progress fractionCompleted]]; +} + +- (void)testExplicitChild +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:100], + child = [CPProgress progressWithTotalUnitCount:4 parent:parent pendingUnitCount:50]; + + [child setCompletedUnitCount:2]; + + [self assert:0.25 equals:[parent fractionCompleted]]; + [self assert:0 equals:[parent completedUnitCount]]; + + [child setCompletedUnitCount:4]; + + [self assert:50 equals:[parent completedUnitCount]]; + [self assert:0.5 equals:[parent fractionCompleted]]; +} + +- (void)testFinishedChildFinishesAncestors +{ + var root = [CPProgress discreteProgressWithTotalUnitCount:1], + middle = [CPProgress progressWithTotalUnitCount:1 parent:root pendingUnitCount:1], + leaf = [CPProgress progressWithTotalUnitCount:2 parent:middle pendingUnitCount:1]; + + [leaf setCompletedUnitCount:2]; + + [self assertTrue:[middle isFinished]]; + [self assertTrue:[root isFinished]]; + [self assert:1.0 equals:[root fractionCompleted]]; +} + +- (void)testImplicitChild +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:10]; + + [parent becomeCurrentWithPendingUnitCount:6]; + + var child = [CPProgress progressWithTotalUnitCount:2], + second = [CPProgress progressWithTotalUnitCount:2]; + + [parent resignCurrent]; + + [self assertNull:[CPProgress currentProgress]]; + + [child setCompletedUnitCount:1]; + [second setCompletedUnitCount:2]; + + [self assert:0.3 equals:[parent fractionCompleted]]; +} + +- (void)testResignWithoutChildCompletesPendingUnits +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:10]; + + [parent performAsCurrentWithPendingUnitCount:3 usingBlock:function() {}]; + + [self assert:3 equals:[parent completedUnitCount]]; +} + +- (void)testParentMustBeCurrent +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:10]; + + [self assertThrows:function() + { + [[CPProgress alloc] initWithParent:parent userInfo:nil]; + }]; + + [self assertThrows:function() + { + [parent resignCurrent]; + }]; +} + +- (void)testCancellationReachesChildren +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:10], + child = [CPProgress progressWithTotalUnitCount:10 parent:parent pendingUnitCount:5], + calls = 0; + + [child setCancellationHandler:function() { calls++; }]; + [parent cancel]; + [parent cancel]; + + [self assertTrue:[parent isCancelled]]; + [self assertTrue:[child isCancelled]]; + [self assert:1 equals:calls]; + + var late = [CPProgress progressWithTotalUnitCount:1 parent:parent pendingUnitCount:1]; + + [self assertTrue:[late isCancelled]]; +} + +- (void)testPausingAndResuming +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:10], + child = [CPProgress progressWithTotalUnitCount:10 parent:parent pendingUnitCount:5], + events = []; + + [child setPausingHandler:function() { events.push(@"pause"); }]; + [child setResumingHandler:function() { events.push(@"resume"); }]; + + [parent pause]; + + [self assertTrue:[child isPaused]]; + + [parent resume]; + + [self assertFalse:[child isPaused]]; + [self assert:[@"pause", @"resume"] equals:events]; +} + +- (void)testObservingFractionCompletedThroughChild +{ + var parent = [CPProgress discreteProgressWithTotalUnitCount:2], + child = [CPProgress progressWithTotalUnitCount:4 parent:parent pendingUnitCount:2], + observer = [[CPProgressTestObserver alloc] init]; + + [parent addObserver:observer forKeyPath:@"fractionCompleted" options:CPKeyValueObservingOptionNew context:nil]; + + [child setCompletedUnitCount:1]; + [child setCompletedUnitCount:4]; + + [parent removeObserver:observer forKeyPath:@"fractionCompleted"]; + + [self assert:[0.25, 1.0] equals:[observer values]]; +} + +- (void)testUserInfo +{ + var progress = [CPProgress discreteProgressWithTotalUnitCount:1]; + + [progress setThroughput:12]; + [progress setFileOperationKind:CPProgressFileOperationKindReceiving]; + + [self assert:12 equals:[progress throughput]]; + [self assert:CPProgressFileOperationKindReceiving equals:[[progress userInfo] objectForKey:CPProgressFileOperationKindKey]]; + + [progress setThroughput:nil]; + + [self assertNull:[progress throughput]]; +} + +@end