From 2e3be3b58b79cb6460f947c711c6edb5b2b76b1f Mon Sep 17 00:00:00 2001 From: Lily Ballard Date: Thu, 21 May 2020 23:43:41 -0700 Subject: [PATCH] =?UTF-8?q?Allow=20when(fulfilled:=E2=80=A6)=20to=20resolv?= =?UTF-8?q?e=20synchronously=20if=20possible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #52. --- README.md | 3 + Sources/ObjC/TWLWhen.m | 49 ++++++----- Sources/When.swift | 169 ++++++++++++++++++++++---------------- Tests/ObjC/TWLWhenTests.m | 49 +++++++++++ Tests/WhenTests.swift | 130 +++++++++++++++++++++++++++++ 5 files changed, 310 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 2b3893f..897c0f7 100644 --- a/README.md +++ b/README.md @@ -423,11 +423,14 @@ Unless you explicitly state otherwise, any contribution intentionally submitted already been resolved this will cause the returned promise to likewise already be resolved ([#50][]). - Ensure `when(first:cancelRemaining:)` returns an already-cancelled promise if all input promises were previously cancelled, instead of cancelling the returned promise asynchronously ([#51][]). +- Ensure `when(fulfilled:qos:cancelOnFailure:)` returns an already-resolved promise if either all input promises were previously fulfliled or any input + promise was previously rejected or cancelled ([#52][]). [#34]: https://github.com/lilyball/Tomorrowland/issues/34 "Add a .mainImmediate context" [#47]: https://github.com/lilyball/Tomorrowland/issues/47 "Add Promise.Resolver.isCancelled property" [#50]: https://github.com/lilyball/Tomorrowland/issues/50 "Consider changing timeout's default context to .nowOr(.auto)" [#51]: https://github.com/lilyball/Tomorrowland/issues/51 "when(first:cancelRemaining:) won't cancel synchronously if all inputs have cancelled" +[#52]: https://github.com/lilyball/Tomorrowland/issues/52 "when(fulfilled:…) should be able to resolve synchronously" ### v1.1.1 diff --git a/Sources/ObjC/TWLWhen.m b/Sources/ObjC/TWLWhen.m index 81fccd1..99acc28 100644 --- a/Sources/ObjC/TWLWhen.m +++ b/Sources/ObjC/TWLWhen.m @@ -50,26 +50,28 @@ @implementation TWLPromise (When) NSUInteger count = promises.count; id _Nullable __unsafe_unretained * _Nonnull resultBuffer = (id _Nullable __unsafe_unretained *)calloc((size_t)count, sizeof(id)); dispatch_group_t group = dispatch_group_create(); - TWLContext *context = [TWLContext contextForQoS:qosClass]; + TWLContext *context = [TWLContext nowOrContext:[TWLContext contextForQoS:qosClass]]; for (NSUInteger i = 0; i < count; ++i) { TWLPromise *promise = promises[i]; dispatch_group_enter(group); [promise enqueueCallbackWithoutOneshot:^(id _Nullable value, id _Nullable error, BOOL isSynchronous) { - [context executeIsSynchronous:isSynchronous block:^{ - if (value) { - resultBuffer[i] = (__bridge id)CFBridgingRetain(value); - } else if (error) { + if (value) { + resultBuffer[i] = (__bridge id)CFBridgingRetain(value); + } else if (error) { + [context executeIsSynchronous:isSynchronous block:^{ [resolver rejectWithError:error]; - [cancelAllInput invoke]; - } else { + }]; + [cancelAllInput invoke]; + } else { + [context executeIsSynchronous:isSynchronous block:^{ [resolver cancel]; - [cancelAllInput invoke]; - } - dispatch_group_leave(group); - }]; + }]; + [cancelAllInput invoke]; + } + dispatch_group_leave(group); } willPropagateCancel:YES]; } - dispatch_group_notify(group, dispatch_get_global_queue(qosClass, 0), ^{ + dispatch_block_t handler = ^{ @try { for (NSUInteger i = 0; i < count; ++i) { if (resultBuffer[i] == NULL) { @@ -84,16 +86,21 @@ @implementation TWLPromise (When) (void)CFBridgingRelease((__bridge CFTypeRef)(resultBuffer[i])); } } - }); - NSHashTable *boxes = [NSHashTable weakObjectsHashTable]; - for (TWLPromise *promise in promises) { - [boxes addObject:promise->_box]; - } - [resolver whenCancelRequestedOnContext:TWLContext.immediate handler:^(TWLResolver * _Nonnull resolver) { - for (TWLObjCPromiseBox *box in boxes) { - [box propagateCancel]; + }; + if (dispatch_group_wait(group, DISPATCH_TIME_NOW) == 0) { + handler(); + } else { + dispatch_group_notify(group, dispatch_get_global_queue(qosClass, 0), handler); + NSHashTable *boxes = [NSHashTable weakObjectsHashTable]; + for (TWLPromise *promise in promises) { + [boxes addObject:promise->_box]; } - }]; + [resolver whenCancelRequestedOnContext:TWLContext.immediate handler:^(TWLResolver * _Nonnull resolver) { + for (TWLObjCPromiseBox *box in boxes) { + [box propagateCancel]; + } + }]; + } return resultPromise; } diff --git a/Sources/When.swift b/Sources/When.swift index 58da06d..aa404e9 100644 --- a/Sources/When.swift +++ b/Sources/When.swift @@ -54,26 +54,28 @@ public func when(fulfilled promises: [Promise], qos: D var resultBuffer = UnsafeMutablePointer.allocate(capacity: count) resultBuffer.initialize(repeating: nil, count: count) let group = DispatchGroup() - let context = PromiseContext(qos: qos) + let context = PromiseContext.nowOr(.init(qos: qos)) for (i, promise) in promises.enumerated() { group.enter() promise._seal._enqueue { (result, isSynchronous) in - context.execute(isSynchronous: isSynchronous) { - switch result { - case .value(let value): - resultBuffer[i] = value - case .error(let error): + switch result { + case .value(let value): + resultBuffer[i] = value + case .error(let error): + context.execute(isSynchronous: isSynchronous) { resolver.reject(with: error) - cancelAllInput?.invoke() - case .cancelled: + } + cancelAllInput?.invoke() + case .cancelled: + context.execute(isSynchronous: isSynchronous) { resolver.cancel() - cancelAllInput?.invoke() } - group.leave() + cancelAllInput?.invoke() } + group.leave() } } - group.notify(queue: .global(qos: qos)) { + let handler: @convention(block) () -> Void = { defer { resultBuffer.deinitialize(count: count) resultBuffer.deallocate() @@ -90,9 +92,14 @@ public func when(fulfilled promises: [Promise], qos: D } resolver.fulfill(with: Array(results)) } - resolver.onRequestCancel(on: .immediate) { [boxes=promises.map({ Weak($0._box) })] (resolver) in - for box in boxes { - box.value?.propagateCancel() + if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { + handler() + } else { + group.notify(queue: .global(qos: qos), execute: handler) + resolver.onRequestCancel(on: .immediate) { [boxes=promises.map({ Weak($0._box) })] (resolver) in + for box in boxes { + box.value?.propagateCancel() + } } } return resultPromise @@ -161,7 +168,7 @@ public func when(fulfilled a: P } } - let context = PromiseContext(qos: qos) + let context = PromiseContext.nowOr(.init(qos: qos)) group.enter() tap(a, on: context, { resolver.handleResult($0, output: &aResult, cancelAllInput: cancelAllInput); group.leave() }) group.enter() @@ -175,23 +182,28 @@ public func when(fulfilled a: P group.enter() tap(f, on: context, { resolver.handleResult($0, output: &fResult, cancelAllInput: cancelAllInput); group.leave() }) - group.notify(queue: .global(qos: qos)) { + let handler: @convention(block) () -> Void = { guard let a = aResult, let b = bResult, let c = cResult, let d = dResult, let e = eResult, let f = fResult else { // Must have had a rejected or cancelled promise return } resolver.fulfill(with: (a,b,c,d,e,f)) } - resolver.onRequestCancel(on: .immediate, { - [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box, weak boxD=d._box, weak boxE=e._box, weak boxF=f._box] - (resolver) in - boxA?.propagateCancel() - boxB?.propagateCancel() - boxC?.propagateCancel() - boxD?.propagateCancel() - boxE?.propagateCancel() - boxF?.propagateCancel() - }) + if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { + handler() + } else { + group.notify(queue: .global(qos: qos), execute: handler) + resolver.onRequestCancel(on: .immediate, { + [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box, weak boxD=d._box, weak boxE=e._box, weak boxF=f._box] + (resolver) in + boxA?.propagateCancel() + boxB?.propagateCancel() + boxC?.propagateCancel() + boxD?.propagateCancel() + boxE?.propagateCancel() + boxF?.propagateCancel() + }) + } return resultPromise } @@ -254,7 +266,7 @@ public func when(fulfilled a: Promise< } } - let context = PromiseContext(qos: qos) + let context = PromiseContext.nowOr(.init(qos: qos)) group.enter() tap(a, on: context, { resolver.handleResult($0, output: &aResult, cancelAllInput: cancelAllInput); group.leave() }) group.enter() @@ -266,22 +278,27 @@ public func when(fulfilled a: Promise< group.enter() tap(e, on: context, { resolver.handleResult($0, output: &eResult, cancelAllInput: cancelAllInput); group.leave() }) - group.notify(queue: .global(qos: qos)) { + let handler: @convention(block) () -> Void = { guard let a = aResult, let b = bResult, let c = cResult, let d = dResult, let e = eResult else { // Must have had a rejected or cancelled promise return } resolver.fulfill(with: (a,b,c,d,e)) } - resolver.onRequestCancel(on: .immediate, { - [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box, weak boxD=d._box, weak boxE=e._box] - (resolver) in - boxA?.propagateCancel() - boxB?.propagateCancel() - boxC?.propagateCancel() - boxD?.propagateCancel() - boxE?.propagateCancel() - }) + if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { + handler() + } else { + group.notify(queue: .global(qos: qos), execute: handler) + resolver.onRequestCancel(on: .immediate, { + [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box, weak boxD=d._box, weak boxE=e._box] + (resolver) in + boxA?.propagateCancel() + boxB?.propagateCancel() + boxC?.propagateCancel() + boxD?.propagateCancel() + boxE?.propagateCancel() + }) + } return resultPromise } @@ -341,7 +358,7 @@ public func when(fulfilled a: Promise(fulfilled a: Promise Void = { guard let a = aResult, let b = bResult, let c = cResult, let d = dResult else { // Must have had a rejected or cancelled promise return } resolver.fulfill(with: (a,b,c,d)) } - resolver.onRequestCancel(on: .immediate, { - [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box, weak boxD=d._box] - (resolver) in - boxA?.propagateCancel() - boxB?.propagateCancel() - boxC?.propagateCancel() - boxD?.propagateCancel() - }) + if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { + handler() + } else { + group.notify(queue: .global(qos: qos), execute: handler) + resolver.onRequestCancel(on: .immediate, { + [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box, weak boxD=d._box] + (resolver) in + boxA?.propagateCancel() + boxB?.propagateCancel() + boxC?.propagateCancel() + boxD?.propagateCancel() + }) + } return resultPromise } @@ -422,7 +444,7 @@ public func when(fulfilled a: Promise, } } - let context = PromiseContext(qos: qos) + let context = PromiseContext.nowOr(.init(qos: qos)) group.enter() tap(a, on: context, { resolver.handleResult($0, output: &aResult, cancelAllInput: cancelAllInput); group.leave() }) group.enter() @@ -430,20 +452,25 @@ public func when(fulfilled a: Promise, group.enter() tap(c, on: context, { resolver.handleResult($0, output: &cResult, cancelAllInput: cancelAllInput); group.leave() }) - group.notify(queue: .global(qos: qos)) { + let handler: @convention(block) () -> Void = { guard let a = aResult, let b = bResult, let c = cResult else { // Must have had a rejected or cancelled promise return } resolver.fulfill(with: (a,b,c)) } - resolver.onRequestCancel(on: .immediate, { - [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box] - (resolver) in - boxA?.propagateCancel() - boxB?.propagateCancel() - boxC?.propagateCancel() - }) + if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { + handler() + } else { + group.notify(queue: .global(qos: qos), execute: handler) + resolver.onRequestCancel(on: .immediate, { + [weak boxA=a._box, weak boxB=b._box, weak boxC=c._box] + (resolver) in + boxA?.propagateCancel() + boxB?.propagateCancel() + boxC?.propagateCancel() + }) + } return resultPromise } @@ -497,25 +524,30 @@ public func when(fulfilled a: Promise, } } - let context = PromiseContext(qos: qos) + let context = PromiseContext.nowOr(.init(qos: qos)) group.enter() tap(a, on: context, { resolver.handleResult($0, output: &aResult, cancelAllInput: cancelAllInput); group.leave() }) group.enter() tap(b, on: context, { resolver.handleResult($0, output: &bResult, cancelAllInput: cancelAllInput); group.leave() }) - group.notify(queue: .global(qos: qos)) { + let handler: @convention(block) () -> Void = { guard let a = aResult, let b = bResult else { // Must have had a rejected or cancelled promise return } resolver.fulfill(with: (a,b)) } - resolver.onRequestCancel(on: .immediate, { - [weak boxA=a._box, weak boxB=b._box] - (resolver) in - boxA?.propagateCancel() - boxB?.propagateCancel() - }) + if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { + handler() + } else { + group.notify(queue: .global(qos: qos), execute: handler) + resolver.onRequestCancel(on: .immediate, { + [weak boxA=a._box, weak boxB=b._box] + (resolver) in + boxA?.propagateCancel() + boxB?.propagateCancel() + }) + } return resultPromise } @@ -585,15 +617,14 @@ public func when(first promises: [Promise], cancelRema // DispatchTime(uptimeNanoseconds: 0) produces DISPATCH_TIME_NOW and is faster than using .now() if group.wait(timeout: DispatchTime(uptimeNanoseconds: 0)) == .success { resolver.cancel() - return newPromise } else { group.notify(queue: .global(qos: .utility)) { resolver.cancel() } - } - resolver.onRequestCancel(on: .immediate) { [boxes=promises.map({ Weak($0._box) })] (resolver) in - for box in boxes { - box.value?.propagateCancel() + resolver.onRequestCancel(on: .immediate) { [boxes=promises.map({ Weak($0._box) })] (resolver) in + for box in boxes { + box.value?.propagateCancel() + } } } return newPromise diff --git a/Tests/ObjC/TWLWhenTests.m b/Tests/ObjC/TWLWhenTests.m index ffccd22..d4b35f4 100644 --- a/Tests/ObjC/TWLWhenTests.m +++ b/Tests/ObjC/TWLWhenTests.m @@ -215,6 +215,55 @@ - (void)testWhenCancelPropagation { [self waitForExpectations:expectations timeout:1]; } +- (void)testWhenWithPreFulfilledInput { + // If all inputs have already fulfilled, return a pre-fulfilled promise + NSMutableArray*> *promises = [NSMutableArray new]; + for (NSUInteger i = 0; i < 3; ++i) { + [promises addObject:[TWLPromise newFulfilledWithValue:@(i)]]; + } + __auto_type promise = [TWLPromise whenFulfilled:promises qos:QOS_CLASS_BACKGROUND]; + NSArray *value; + XCTAssertTrue([promise getValue:&value error:NULL]); + XCTAssertEqualObjects(value, (@[@0,@1,@2])); +} + +- (void)testWhenWithPreRejectedInput { + // If any input has already rejected, return a pre-rejected promise + NSMutableArray*> *promises = [NSMutableArray new]; + for (NSUInteger i = 0; i < 3; ++i) { + [promises addObject:(i == 2 + ? [TWLPromise newRejectedWithError:@"foo"] + : [TWLPromise newOnContext:TWLContext.immediate withBlock:^(TWLResolver * _Nonnull resolver) { + [resolver whenCancelRequestedOnContext:TWLContext.immediate handler:^(TWLResolver * _Nonnull innerResolver) { + [resolver cancel]; // capture resolver + }]; + }])]; + } + __auto_type promise = [TWLPromise whenFulfilled:promises qos:QOS_CLASS_BACKGROUND]; + NSString *error; + XCTAssertTrue([promise getValue:NULL error:&error]); + XCTAssertEqualObjects(error, @"foo"); +} + +- (void)testWhenWithPreCancelledInput { + // If any input has already cancelled, return a pre-cancelled promise + NSMutableArray*> *promises = [NSMutableArray new]; + for (NSUInteger i = 0; i < 3; ++i) { + [promises addObject:(i == 2 + ? [TWLPromise newCancelled] + : [TWLPromise newOnContext:TWLContext.immediate withBlock:^(TWLResolver * _Nonnull resolver) { + [resolver whenCancelRequestedOnContext:TWLContext.immediate handler:^(TWLResolver * _Nonnull innerResolver) { + [resolver cancel]; // capture resolver + }]; + }])]; + } + __auto_type promise = [TWLPromise whenFulfilled:promises qos:QOS_CLASS_BACKGROUND]; + id value, error; + XCTAssertTrue([promise getValue:&value error:&error]); + XCTAssertNil(value); + XCTAssertNil(error); +} + #pragma mark - - (void)testRace { diff --git a/Tests/WhenTests.swift b/Tests/WhenTests.swift index c7c1fb2..95f2aaa 100644 --- a/Tests/WhenTests.swift +++ b/Tests/WhenTests.swift @@ -227,6 +227,53 @@ final class WhenArrayTests: XCTestCase { sema.signal() wait(for: expectations, timeout: 1) } + + func testWhenWithPreFulfilledInput() { + // If all inputs have already fulfilled, return a pre-fulfilled promise + let promises = (1...3).map(Promise.init(fulfilled:)) + let promise = when(fulfilled: promises, qos: .background) + XCTAssertEqual(promise.result, .value([1,2,3])) + } + + func testWhenWithPreRejectedInput() { + // If any input has already rejected, return a pre-rejected promise + let promises = (1...3).map({ (i) in + return i == 2 + ? Promise(rejected: "foo") + : Promise(on: .default, { (resolver) in + resolver.onRequestCancel(on: .immediate, { _ in + resolver.cancel() // capture resolver + }) + }) + }) + addTeardownBlock { + for promise in promises { + promise.requestCancel() // just in case + } + } + let promise = when(fulfilled: promises, qos: .background, cancelOnFailure: true) + XCTAssertEqual(promise.result, .error("foo")) + } + + func testWhenWithPreCancelledInput() { + // If any input has already cancelled, return a pre-cancelled promise + let promises = (1...3).map({ (i) in + return i == 2 + ? Promise(with: .cancelled) + : Promise(on: .default, { (resolver) in + resolver.onRequestCancel(on: .immediate, { _ in + resolver.cancel() // capture resolver + }) + }) + }) + addTeardownBlock { + for promise in promises { + promise.requestCancel() // just in case + } + } + let promise = when(fulfilled: promises, qos: .background, cancelOnFailure: true) + XCTAssertEqual(promise.result, .cancelled) + } } final class WhenTupleTests: XCTestCase { @@ -502,6 +549,89 @@ final class WhenTupleTests: XCTestCase { helper(n: 3, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], cancelOnFailure: true) }, splat: splat) helper(n: 2, when: { ps in when(fulfilled: ps[0], ps[1], cancelOnFailure: true) }, splat: splat) } + + func testWhenWithPreFulfilledInput() { + // If all inputs have already fulfilled, return a pre-fulfilled promise + func helper(n: Int, when: ([Promise]) -> Promise, splat: @escaping (Value) -> [Int]) { + let promises = (1...n).map(Promise.init(fulfilled:)) + let promise = when(promises) + switch promise.result { + case .value(let values): + XCTAssertEqual(splat(values), Array(1...n), "promise values") + case let result: + XCTFail("Expected values, got \(result as Any)") + } + } + helper(n: 6, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], ps[4], ps[5], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 5, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], ps[4], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 4, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 3, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 2, when: { ps in when(fulfilled: ps[0], ps[1], qos: .background, cancelOnFailure: true) }, splat: splat) + } + + func testWhenWithPreRejectedInput() { + // If any input has already rejected, return a pre-rejected promise + func helper(n: Int, when: ([Promise]) -> Promise, splat: @escaping (Value) -> [Int]) { + let promises = (1...n).map({ (i) in + return i == 2 + ? Promise(rejected: "foo") + : Promise(on: .default, { (resolver) in + resolver.onRequestCancel(on: .immediate, { _ in + resolver.cancel() // capture resolver + }) + }) + }) + addTeardownBlock { + for promise in promises { + promise.requestCancel() // just in case + } + } + let promise = when(promises) + switch promise.result { + case .error(let error): + XCTAssertEqual(error, "foo", "promise error") + case let result: + XCTFail("Expected error, got \(result as Any)") + } + } + helper(n: 6, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], ps[4], ps[5], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 5, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], ps[4], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 4, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 3, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 2, when: { ps in when(fulfilled: ps[0], ps[1], qos: .background, cancelOnFailure: true) }, splat: splat) + } + + func testWhenWithPreCancelledInput() { + // If any input has already cancelled, return a pre-cancelled promise + func helper(n: Int, when: ([Promise]) -> Promise, splat: @escaping (Value) -> [Int]) { + let promises = (1...n).map({ (i) in + return i == 2 + ? Promise(with: .cancelled) + : Promise(on: .default, { (resolver) in + resolver.onRequestCancel(on: .immediate, { _ in + resolver.cancel() // capture resolver + }) + }) + }) + addTeardownBlock { + for promise in promises { + promise.requestCancel() // just in case + } + } + let promise = when(promises) + switch promise.result { + case .cancelled: + break + case let result: + XCTFail("Expected cancelled, got \(result as Any)") + } + } + helper(n: 6, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], ps[4], ps[5], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 5, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], ps[4], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 4, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], ps[3], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 3, when: { ps in when(fulfilled: ps[0], ps[1], ps[2], qos: .background, cancelOnFailure: true) }, splat: splat) + helper(n: 2, when: { ps in when(fulfilled: ps[0], ps[1], qos: .background, cancelOnFailure: true) }, splat: splat) + } } final class WhenFirstTests: XCTestCase {