1

I'm implementing NSProgress support in a library, and I wrote some unit tests to test that everything's working correctly. While ideally I'd like to be able to pass some additional metadata (userInfo keys not used by NSProgress itself, but for users of my API to consume), for now I'm just trying to get localizedDescription and localizedAdditionalDescription to work like the documentation says they should. Since the method I'm testing extracts files from an archive, I set the kind to NSProgressKindFile and set the various keys associated with file operations (e.g. NSProgressFileCompletedCountKey).

I expect when I observe changes to localizedDescription with KVO, that I'll see updates like this:

Processing “Test File A.txt”

Processing “Test File B.jpg”

Processing “Test File C.m4a”

When I stop at a breakpoint and po the localizedDescription on the worker NSProgress instance (childProgress below), that is in fact what I see. But when my tests run, all they see is the following, implying it's not seeing any of the userInfo keys I set:

0% completed

0% completed

53% completed

100% completed

100% completed

It looks like the userInfo keys I set on a child NSProgress instance are not getting passed on to its parent, even though fractionCompleted does. Am I doing something wrong?

I give some abstract code snippets below, but you can also download the commit with these changes from GitHub. If you'd like to reproduce this behavior, run the -[ProgressReportingTests testProgressReporting_ExtractFiles_Description] and -[ProgressReportingTests testProgressReporting_ExtractFiles_AdditionalDescription] test cases.

In my test case class:

static void *ProgressContext = &ProgressContext;

...

- (void)testProgressReporting {
    NSProgress *parentProgress = [NSProgress progressWithTotalUnitCount:1];
    [parentProgress becomeCurrentWithPendingUnitCount:1];

    [parentProgress addObserver:self
                     forKeyPath:NSStringFromSelector(@selector(localizedDescription))
                        options:NSKeyValueObservingOptionInitial
                        context:ProgressContext];

    MyAPIClass *apiObject = // initialize
    [apiObject doLongRunningThing];

    [parentProgress resignCurrent];
    [parentProgress removeObserver:self
                        forKeyPath:NSStringFromSelector(@selector(localizedDescription))];
}


- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change
                       context:(void *)context
{
    if (context == ProgressContext) {
        // Should refer to parentProgress from above
        NSProgress *notificationProgress = object;
        
        [self.descriptionArray addObject:notificationProgress.localizedDescription];
    }
}

Then, in my class under test:

- (void) doLongRunningThing {
    ...
    NSProgress *childProgress = [NSProgress progressWithTotalUnitCount:/* bytes calculated above */];
    progress.kind = NSProgressKindFile;
    [childProgress setUserInfoObject:@0
                              forKey:NSProgressFileCompletedCountKey];
    [childProgress setUserInfoObject:@(/*array count from above*/)
                              forKey:NSProgressFileTotalCountKey];

    int counter = 0;

    for /* Long-running loop */ {
        [childProgress setUserInfoObject: // a file URL
                                  forKey:NSProgressFileURLKey];

        // Do stuff

        [childProgress setUserInfoObject:@(++counter)
                                  forKey:NSProgressFileCompletedCountKey];
        childProgress.completedUnitCount += myIncrement;
    }
}

At the time I increment childProgress.completedUnitCount, this is what the userInfo looks like in the debugger. The fields I set are all represented:

> po childProgress.userInfo

{
    NSProgressFileCompletedCountKey = 2,
    NSProgressFileTotalCountKey = 3,
    NSProgressFileURLKey = "file:///...Test%20File%20B.jpg"; // chunk elided from URL
}

When each KVO notification comes back, this is how notificationProgress.userInfo looks:

> po notificationProgress.userInfo

{
}
Community
  • 1
  • 1
Dov
  • 15,530
  • 13
  • 76
  • 177
  • Can you show some more code for context? What do po myCustomObject, po MyCustomKey, and po [childProgress userInfo] in the console show immediately after calling setUserInfoObject? – clarus Sep 19 '17 at 14:56
  • @clarus I added the `po` output. What other code would you need for context? There isn't much else as it pertains to `NSProgress`. The `fractionCompleted` is getting reported accurately. – Dov Sep 19 '17 at 20:41
  • What does the code in observeValueForKeyPath:ofObject:change:context: look like? And, where you use parentProgress above, is that the same as the original childProgress object reference? – clarus Sep 19 '17 at 21:04
  • @clarus Thanks for the suggestion. I updated with more thorough code examples. It should hopefully clear up your question. – Dov Sep 20 '17 at 12:22
  • @clarus As I've worked on this more, it looks like even documented `userInfo` keys aren't working as expected. I've updated the question, and included a link to a GitHub commit you could download and run. – Dov Sep 21 '17 at 14:15
  • I posted an answer to make what I'm seeing more readable than I can in these comments. I have to run, but I'll try to look at this more this evening. – clarus Sep 21 '17 at 15:20

2 Answers2

4

I wanted to comment on @clarus's answer, but SO won't let me do readable formatting in a comment. TL;DR - their take has always been my understanding and it's something that bit me when I started working with NSProgress a few years back.

For stuff like this, I like to check the Swift Foundation code for implementation hints. It's maybe not 100% authoritative if stuff's not done yet, but I like seeing the general thinking.

If you look at the implementation of setUserInfoObject(: forKey:), you can see that the implementation simply sets the user info dict without propagating anything up to the parent.

Conversely, updates that impact the child's fraction completed explicitly call back to the (private) _parent property to indicate its state should update in response to a child change.

That private _updateChild(: from: to: portion:) only seems concerned updating the fraction completed and not anything related to the user info dictionary.

Jablair
  • 5,036
  • 4
  • 28
  • 37
3

Ok, I had a chance to look at the code again with more coffee in my system and more time on my hands. I'm actually seeing it working.

In your testProgressReporting_ExtractFiles_AdditionalDescription method, I changed the code to this:

NSProgress *extractFilesProgress = [NSProgress progressWithTotalUnitCount:1];
[extractFilesProgress setUserInfoObject:@10 forKey:NSProgressEstimatedTimeRemainingKey];
[extractFilesProgress setUserInfoObject:@"Test" forKey:@"TestKey"];

And then in observeValueForKeyPath, I printed these objects:

po progress.userInfo {
NSProgressEstimatedTimeRemainingKey = 10;
TestKey = Test;
}

po progress.localizedAdditionalDescription
0 of 1 — About 10 seconds remaining

You can see the key-values I added, and the localizedAdditionalDescription was created based on those entries (notice the time remaining). So, this all looks like it's working correctly.

I think one point of confusion might be around the NSProgress properties and their effect on the key-values in the userInfo dict. Setting the properties doesn't add key-values to the userInfo dict, and setting the key-values doesn't set the properties. For example, setting the progress kind doesn't add the NSProgressFileOperationKindKey to the userInfo dict. The value in the userInfo dict, if present, is more of an override of the property that's only used when creating the localizedAdditionalDescription.

You can also see the custom key-value I added. So, this all looks like it's working right. Can you point me to something that still looks off?

clarus
  • 2,455
  • 18
  • 19
  • That's interesting. I didn't think to look deeper into the parent progress. I do think that it's supposed to work the way I'm intending, though. Hierarchy is deeply ingrained into the way NSProgress is meant to function. Thanks for taking the time to check it out. – Dov Sep 21 '17 at 15:23
  • updated the text after looking at it more closely, removed my comments from earlier because they didn't add anything. – clarus Sep 22 '17 at 00:01
  • thanks for sticking with this! I appreciate it. I think where making that change falls short is that the updates in the `URKArchive` class's worker `NSProgress` still don't propagate up. When I `po` on the `progress` object in `URKArchive`, it always did show all keys I set. Does that make sense? – Dov Sep 22 '17 at 00:18
  • So, when you say "propagate up," is it your expectation that the child processes' userInfo dicts are going to be merged into the parent's? Because I don't see anything that indicates that would happen, and I'd expect the userInfo dict to be specific to a particular object. Otherwise, you might overwrite another child's keys or even the parent's without them expecting it. If you want something exposed to another progress object, say the parent, then I think you need to add those values to the parent's userInfo, or otherwise make them available outside of the child object. – clarus Sep 22 '17 at 01:14
  • The parent is just keeping track of overall progress, so I can't see why it would be desirable for a child to potentially overwrite the parent's keys for formatting the localization string--I'd think the parent would expect to use its own. Is there documentation or an example somewhere that suggested that would happen, or am I misunderstanding the issue? – clarus Sep 22 '17 at 01:14
  • 1
    So yeah, apparently, userInfo values don't propagate upward. I've updated my class to implement `NSProgressReporting`, so consumers who want the better progress information will need to use that API. – Dov Sep 23 '17 at 22:12