mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-20 09:11:33 +01:00
* [FIX] Replace deprecated viewStore with store * [FIX] Replace deprecated viewStore with store in Article * [TEST] Match the changed view store message with the test message * [TEST] Match the changed view store message with the test message
1329 lines
37 KiB
Swift
1329 lines
37 KiB
Swift
@_spi(Internals) import ComposableArchitecture
|
||
import XCTest
|
||
|
||
@available(*, deprecated, message: "TODO: Update to use case pathable syntax with Swift 5.9")
|
||
final class StackReducerTests: BaseTCATestCase {
|
||
func testStackStateSubscriptCase() {
|
||
enum Element: Equatable {
|
||
case int(Int)
|
||
case text(String)
|
||
}
|
||
|
||
var stack = StackState<Element>([.int(42)])
|
||
stack[id: 0, case: /Element.int]? += 1
|
||
XCTAssertEqual(stack[id: 0], .int(43))
|
||
|
||
stack[id: 0, case: /Element.int] = nil
|
||
XCTAssertTrue(stack.isEmpty)
|
||
}
|
||
|
||
func testStackStateSubscriptCase_Unexpected() {
|
||
enum Element: Equatable {
|
||
case int(Int)
|
||
case text(String)
|
||
}
|
||
|
||
var stack = StackState<Element>([.int(42)])
|
||
|
||
XCTExpectFailure {
|
||
stack[id: 0, case: /Element.text]?.append("!")
|
||
} issueMatcher: {
|
||
$0.compactDescription == """
|
||
failed - Can't modify unrelated case "int"
|
||
"""
|
||
}
|
||
|
||
XCTExpectFailure {
|
||
stack[id: 0, case: /Element.text] = nil
|
||
} issueMatcher: {
|
||
$0.compactDescription == """
|
||
failed - Can't modify unrelated case "int"
|
||
"""
|
||
}
|
||
|
||
XCTAssertEqual(Array(stack), [.int(42)])
|
||
}
|
||
|
||
func testCustomDebugStringConvertible() {
|
||
@Dependency(\.stackElementID) var stackElementID
|
||
XCTAssertEqual(stackElementID.peek().generation, 0)
|
||
XCTAssertEqual(stackElementID.next().customDumpDescription, "#0")
|
||
XCTAssertEqual(stackElementID.peek().generation, 1)
|
||
XCTAssertEqual(stackElementID.next().customDumpDescription, "#1")
|
||
|
||
withDependencies {
|
||
$0.context = .live
|
||
} operation: {
|
||
XCTAssertEqual(stackElementID.next().customDumpDescription, "#0")
|
||
XCTAssertEqual(stackElementID.next().customDumpDescription, "#1")
|
||
}
|
||
}
|
||
|
||
func testPresent() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {
|
||
var count = 0
|
||
}
|
||
enum Action: Equatable {
|
||
case decrementButtonTapped
|
||
case incrementButtonTapped
|
||
}
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .decrementButtonTapped:
|
||
state.count -= 1
|
||
return .none
|
||
case .incrementButtonTapped:
|
||
state.count += 1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case children(StackActionOf<Child>)
|
||
case pushChild
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .pushChild:
|
||
state.children.append(Child.State())
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.children, action: /Action.children) {
|
||
Child()
|
||
}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.pushChild) {
|
||
$0.children.append(Child.State())
|
||
}
|
||
}
|
||
|
||
func testDismissFromParent() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {
|
||
case onAppear
|
||
}
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .onAppear:
|
||
return .run { _ in
|
||
try await Task.never()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case children(StackActionOf<Child>)
|
||
case popChild
|
||
case pushChild
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .popChild:
|
||
state.children.removeLast()
|
||
return .none
|
||
case .pushChild:
|
||
state.children.append(Child.State())
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.children, action: /Action.children) {
|
||
Child()
|
||
}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.pushChild) {
|
||
$0.children.append(Child.State())
|
||
}
|
||
await store.send(.children(.element(id: 0, action: .onAppear)))
|
||
await store.send(.popChild) {
|
||
$0.children.removeLast()
|
||
}
|
||
}
|
||
|
||
func testDismissFromChild() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {
|
||
case closeButtonTapped
|
||
case onAppear
|
||
}
|
||
@Dependency(\.dismiss) var dismiss
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .closeButtonTapped:
|
||
return .run { _ in
|
||
await self.dismiss()
|
||
}
|
||
case .onAppear:
|
||
return .run { _ in
|
||
try await Task.never()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case children(StackActionOf<Child>)
|
||
case pushChild
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .pushChild:
|
||
state.children.append(Child.State())
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.children, action: /Action.children) {
|
||
Child()
|
||
}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.pushChild) {
|
||
$0.children.append(Child.State())
|
||
}
|
||
await store.send(.children(.element(id: 0, action: .onAppear)))
|
||
await store.send(.children(.element(id: 0, action: .closeButtonTapped)))
|
||
await store.receive(.children(.popFrom(id: 0))) {
|
||
$0.children.removeLast()
|
||
}
|
||
}
|
||
|
||
func testDismissReceiveWrongAction() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable { case tap }
|
||
@Dependency(\.dismiss) var dismiss
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
.run { _ in await self.dismiss() }
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case children(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }.forEach(\.children, action: /Action.children) { Child() }
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State(children: StackState([Child.State()]))) {
|
||
Parent()
|
||
}
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - Received unexpected action: …
|
||
|
||
StackReducerTests.Parent.Action.children(
|
||
− .popFrom(id: #1)
|
||
+ .popFrom(id: #0)
|
||
)
|
||
|
||
(Expected: −, Received: +)
|
||
"""
|
||
}
|
||
|
||
await store.send(.children(.element(id: 0, action: .tap)))
|
||
await store.receive(.children(.popFrom(id: 1))) {
|
||
$0.children = StackState()
|
||
}
|
||
}
|
||
|
||
func testDismissFromIntermediateChild() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable { var count = 0 }
|
||
enum Action: Equatable {
|
||
case onAppear
|
||
}
|
||
@Dependency(\.dismiss) var dismiss
|
||
@Dependency(\.mainQueue) var mainQueue
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .onAppear:
|
||
return .run { [count = state.count] _ in
|
||
try await self.mainQueue.sleep(for: .seconds(count))
|
||
await self.dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }
|
||
.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
|
||
let mainQueue = DispatchQueue.test
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
} withDependencies: {
|
||
$0.mainQueue = mainQueue.eraseToAnyScheduler()
|
||
}
|
||
|
||
await store.send(.child(.push(id: 0, state: Child.State(count: 2)))) {
|
||
$0.children[id: 0] = Child.State(count: 2)
|
||
}
|
||
await store.send(.child(.element(id: 0, action: .onAppear)))
|
||
|
||
await store.send(.child(.push(id: 1, state: Child.State(count: 1)))) {
|
||
$0.children[id: 1] = Child.State(count: 1)
|
||
}
|
||
await store.send(.child(.element(id: 1, action: .onAppear)))
|
||
|
||
await store.send(.child(.push(id: 2, state: Child.State(count: 2)))) {
|
||
$0.children[id: 2] = Child.State(count: 2)
|
||
}
|
||
await store.send(.child(.element(id: 2, action: .onAppear)))
|
||
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.child(.popFrom(id: 1))) {
|
||
$0.children.removeLast(2)
|
||
}
|
||
await store.send(.child(.popFrom(id: 0))) {
|
||
$0.children = StackState()
|
||
}
|
||
}
|
||
|
||
func testDismissFromDeepLinkedChild() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {
|
||
case closeButtonTapped
|
||
}
|
||
@Dependency(\.dismiss) var dismiss
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .closeButtonTapped:
|
||
return .run { _ in
|
||
await self.dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case children(StackActionOf<Child>)
|
||
case pushChild
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .pushChild:
|
||
state.children.append(Child.State())
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.children, action: /Action.children) {
|
||
Child()
|
||
}
|
||
}
|
||
}
|
||
|
||
var children = StackState<Child.State>()
|
||
children.append(Child.State())
|
||
let store = await TestStore(initialState: Parent.State(children: children)) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.children(.element(id: 0, action: .closeButtonTapped)))
|
||
await store.receive(.children(.popFrom(id: 0))) {
|
||
$0.children.removeAll()
|
||
}
|
||
}
|
||
|
||
func testEnumChild() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {
|
||
var count = 0
|
||
}
|
||
enum Action: Equatable {
|
||
case closeButtonTapped
|
||
case incrementButtonTapped
|
||
case onAppear
|
||
}
|
||
@Dependency(\.dismiss) var dismiss
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .closeButtonTapped:
|
||
return .run { _ in
|
||
await self.dismiss()
|
||
}
|
||
case .incrementButtonTapped:
|
||
state.count += 1
|
||
return .none
|
||
case .onAppear:
|
||
return .run { _ in
|
||
try await Task.never()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Path: Reducer {
|
||
enum State: Equatable {
|
||
case child1(Child.State)
|
||
case child2(Child.State)
|
||
}
|
||
enum Action: Equatable {
|
||
case child1(Child.Action)
|
||
case child2(Child.Action)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Scope(state: /State.child1, action: /Action.child1) {
|
||
Child()
|
||
}
|
||
Scope(state: /State.child2, action: /Action.child2) {
|
||
Child()
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var path = StackState<Path.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case path(StackActionOf<Path>)
|
||
case pushChild1
|
||
case pushChild2
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .path:
|
||
return .none
|
||
case .pushChild1:
|
||
state.path.append(.child1(Child.State()))
|
||
return .none
|
||
case .pushChild2:
|
||
state.path.append(.child2(Child.State()))
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.path, action: /Action.path) {
|
||
Path()
|
||
}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
await store.send(.pushChild1) {
|
||
$0.path.append(.child1(Child.State()))
|
||
}
|
||
await store.send(.path(.element(id: 0, action: .child1(.onAppear))))
|
||
await store.send(.pushChild2) {
|
||
$0.path.append(.child2(Child.State()))
|
||
}
|
||
await store.send(.path(.element(id: 1, action: .child2(.onAppear))))
|
||
await store.send(.path(.popFrom(id: 0))) {
|
||
$0.path.removeAll()
|
||
}
|
||
}
|
||
|
||
func testParentDismiss() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action { case tap }
|
||
@Dependency(\.dismiss) var dismiss
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
.run { _ in try await Task.never() }
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var path = StackState<Child.State>()
|
||
}
|
||
enum Action {
|
||
case path(StackActionOf<Child>)
|
||
case popToRoot
|
||
case pushChild
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .path:
|
||
return .none
|
||
case .popToRoot:
|
||
state.path.removeAll()
|
||
return .none
|
||
case .pushChild:
|
||
state.path.append(Child.State())
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.path, action: /Action.path) {
|
||
Child()
|
||
}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
await store.send(.pushChild) {
|
||
$0.path.append(Child.State())
|
||
}
|
||
await store.send(.path(.element(id: 0, action: .tap)))
|
||
await store.send(.pushChild) {
|
||
$0.path.append(Child.State())
|
||
}
|
||
await store.send(.path(.element(id: 1, action: .tap)))
|
||
await store.send(.popToRoot) {
|
||
$0.path.removeAll()
|
||
}
|
||
}
|
||
|
||
enum TestSiblingCannotCancel {
|
||
@Reducer
|
||
struct Child {
|
||
struct State: Equatable {
|
||
var count = 0
|
||
}
|
||
enum Action: Equatable {
|
||
case cancel
|
||
case response(Int)
|
||
case tap
|
||
}
|
||
@Dependency(\.mainQueue) var mainQueue
|
||
enum CancelID: Hashable { case cancel }
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .cancel:
|
||
return .cancel(id: CancelID.cancel)
|
||
case let .response(value):
|
||
state.count = value
|
||
return .none
|
||
case .tap:
|
||
return .run { send in
|
||
try await self.mainQueue.sleep(for: .seconds(1))
|
||
await send(.response(42))
|
||
}
|
||
.cancellable(id: CancelID.cancel)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
@Reducer
|
||
struct Path {
|
||
enum State: Equatable {
|
||
case child1(Child.State)
|
||
case child2(Child.State)
|
||
}
|
||
enum Action: Equatable {
|
||
case child1(Child.Action)
|
||
case child2(Child.Action)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Scope(state: \.child1, action: \.child1) { Child() }
|
||
Scope(state: \.child2, action: \.child2) { Child() }
|
||
}
|
||
}
|
||
@Reducer
|
||
struct Parent {
|
||
struct State: Equatable {
|
||
var path = StackState<Path.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case path(StackActionOf<Path>)
|
||
case pushChild1
|
||
case pushChild2
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .path:
|
||
return .none
|
||
case .pushChild1:
|
||
state.path.append(.child1(Child.State()))
|
||
return .none
|
||
case .pushChild2:
|
||
state.path.append(.child2(Child.State()))
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.path, action: \.path) {
|
||
Path()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
func testSiblingCannotCancel() async {
|
||
var path = StackState<TestSiblingCannotCancel.Path.State>()
|
||
path.append(.child1(TestSiblingCannotCancel.Child.State()))
|
||
path.append(.child2(TestSiblingCannotCancel.Child.State()))
|
||
let mainQueue = DispatchQueue.test
|
||
let store = await TestStore(initialState: TestSiblingCannotCancel.Parent.State(path: path)) {
|
||
TestSiblingCannotCancel.Parent()
|
||
} withDependencies: {
|
||
$0.mainQueue = mainQueue.eraseToAnyScheduler()
|
||
}
|
||
|
||
await store.send(.path(.element(id: 0, action: .child1(.tap))))
|
||
await store.send(.path(.element(id: 1, action: .child2(.tap))))
|
||
await store.send(.path(.element(id: 0, action: .child1(.cancel))))
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.path(.element(id: 1, action: .child2(.response(42))))) {
|
||
$0.path[id: 1, case: \.child2]?.count = 42
|
||
}
|
||
|
||
await store.send(.path(.element(id: 0, action: .child1(.tap))))
|
||
await store.send(.path(.element(id: 1, action: .child2(.tap))))
|
||
await store.send(.path(.element(id: 1, action: .child2(.cancel))))
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.path(.element(id: 0, action: .child1(.response(42))))) {
|
||
$0.path[id: 0, case: \.child1]?.count = 42
|
||
}
|
||
}
|
||
|
||
enum TestFirstChildWhileEffectInFlight_DeliversToCorrectID {
|
||
@Reducer
|
||
struct Child {
|
||
let id: Int
|
||
struct State: Equatable {
|
||
var count = 0
|
||
}
|
||
enum Action: Equatable {
|
||
case response(Int)
|
||
case tap
|
||
}
|
||
@Dependency(\.mainQueue) var mainQueue
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case let .response(value):
|
||
state.count += value
|
||
return .none
|
||
case .tap:
|
||
return .run { send in
|
||
try await self.mainQueue.sleep(for: .seconds(self.id))
|
||
await send(.response(self.id))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
@Reducer
|
||
struct Path {
|
||
enum State: Equatable {
|
||
case child1(Child.State)
|
||
case child2(Child.State)
|
||
}
|
||
enum Action: Equatable {
|
||
case child1(Child.Action)
|
||
case child2(Child.Action)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Scope(state: \.child1, action: \.child1) { Child(id: 1) }
|
||
Scope(state: \.child2, action: \.child2) { Child(id: 2) }
|
||
}
|
||
}
|
||
@Reducer
|
||
struct Parent {
|
||
struct State: Equatable {
|
||
var path = StackState<Path.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case path(StackActionOf<Path>)
|
||
case popAll
|
||
case popFirst
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .path:
|
||
return .none
|
||
case .popAll:
|
||
state.path = StackState()
|
||
return .none
|
||
case .popFirst:
|
||
state.path[id: state.path.ids[0]] = nil
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.path, action: \.path) {
|
||
Path()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
func testFirstChildWhileEffectInFlight_DeliversToCorrectID() async {
|
||
let mainQueue = DispatchQueue.test
|
||
let store = await TestStore(
|
||
initialState: TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Parent.State(
|
||
path: StackState([
|
||
.child1(TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Child.State()),
|
||
.child2(TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Child.State()),
|
||
])
|
||
)
|
||
) {
|
||
TestFirstChildWhileEffectInFlight_DeliversToCorrectID.Parent()
|
||
} withDependencies: {
|
||
$0.mainQueue = mainQueue.eraseToAnyScheduler()
|
||
}
|
||
|
||
await store.send(.path(.element(id: 0, action: .child1(.tap))))
|
||
await store.send(.path(.element(id: 1, action: .child2(.tap))))
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.path(.element(id: 0, action: .child1(.response(1))))) {
|
||
$0.path[id: 0, case: \.child1]?.count = 1
|
||
}
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.path(.element(id: 1, action: .child2(.response(2))))) {
|
||
$0.path[id: 1, case: \.child2]?.count = 2
|
||
}
|
||
|
||
await store.send(.path(.element(id: 0, action: .child1(.tap))))
|
||
await store.send(.path(.element(id: 1, action: .child2(.tap))))
|
||
await store.send(.popFirst) {
|
||
$0.path[id: 0] = nil
|
||
}
|
||
await mainQueue.advance(by: .seconds(2))
|
||
await store.receive(.path(.element(id: 1, action: .child2(.response(2))))) {
|
||
$0.path[id: 1, case: \.child2]?.count = 4
|
||
}
|
||
await store.send(.popFirst) {
|
||
$0.path[id: 1] = nil
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func testSendActionWithIDThatDoesNotExist() async {
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var path = StackState<Int>()
|
||
}
|
||
enum Action {
|
||
case path(StackAction<Int, Void>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
EmptyReducer()
|
||
.forEach(\.path, action: /Action.path) {}
|
||
}
|
||
}
|
||
let line = #line - 3
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \
|
||
received an action for a missing element. …
|
||
|
||
Action:
|
||
()
|
||
|
||
This is generally considered an application logic error, and can happen for a few reasons:
|
||
|
||
• A parent reducer removed an element with this ID before this reducer ran. This reducer \
|
||
must run before any other reducer removes an element, which ensures that element \
|
||
reducers can handle their actions while their state is still available.
|
||
|
||
• An in-flight effect emitted this action when state contained no element at this ID. \
|
||
While it may be perfectly reasonable to ignore this action, consider canceling the \
|
||
associated effect before an element is removed, especially if it is a long-living effect.
|
||
|
||
• This action was sent to the store while its state contained no element at this ID. To \
|
||
fix this make sure that actions for this reducer can only be sent from a store when \
|
||
its state contains an element at this id. In SwiftUI applications, use \
|
||
"NavigationStack.init(path:)" with a binding to a store.
|
||
"""
|
||
}
|
||
|
||
var path = StackState<Int>()
|
||
path.append(1)
|
||
let store = TestStore(initialState: Parent.State(path: path)) {
|
||
Parent()
|
||
}
|
||
await store.send(.path(.element(id: 999, action: ())))
|
||
}
|
||
|
||
@MainActor
|
||
func testPopIDThatDoesNotExist() async {
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var path = StackState<Int>()
|
||
}
|
||
enum Action {
|
||
case path(StackAction<Int, Void>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
EmptyReducer()
|
||
.forEach(\.path, action: /Action.path) {}
|
||
}
|
||
}
|
||
let line = #line - 3
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \
|
||
received a "popFrom" action for a missing element. …
|
||
|
||
ID:
|
||
#999
|
||
Path IDs:
|
||
[#0]
|
||
"""
|
||
}
|
||
|
||
let store = TestStore(initialState: Parent.State(path: StackState<Int>([1]))) {
|
||
Parent()
|
||
}
|
||
await store.send(.path(.popFrom(id: 999)))
|
||
}
|
||
|
||
func testChildWithInFlightEffect() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action { case tap }
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
.run { _ in try await Task.never() }
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var path = StackState<Child.State>()
|
||
}
|
||
enum Action {
|
||
case path(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
EmptyReducer()
|
||
.forEach(\.path, action: /Action.path) { Child() }
|
||
}
|
||
}
|
||
|
||
var path = StackState<Child.State>()
|
||
path.append(Child.State())
|
||
let store = await TestStore(initialState: Parent.State(path: path)) {
|
||
Parent()
|
||
}
|
||
let line = #line
|
||
await store.send(.path(.element(id: 0, action: .tap)))
|
||
|
||
XCTExpectFailure {
|
||
$0.sourceCodeContext.location?.fileURL.absoluteString.contains("BaseTCATestCase") == true
|
||
|| $0.sourceCodeContext.location?.lineNumber == line + 1
|
||
&& $0.compactDescription == """
|
||
failed - An effect returned for this action is still running. It must complete before \
|
||
the end of the test. …
|
||
|
||
To fix, inspect any effects the reducer returns for this action and ensure that all \
|
||
of them complete by the end of the test. There are a few reasons why an effect may \
|
||
not have completed:
|
||
|
||
• If using async/await in your effect, it may need a little bit of time to properly \
|
||
finish. To fix you can simply perform "await store.finish()" at the end of your test.
|
||
|
||
• If an effect uses a clock (or scheduler, via "receive(on:)", "delay", "debounce", \
|
||
etc.), make sure that you wait enough time for it to perform the effect. If you are \
|
||
using a test clock/scheduler, advance it so that the effects may complete, or \
|
||
consider using an immediate clock/scheduler to immediately perform the effect instead.
|
||
|
||
• If you are returning a long-living effect (timers, notifications, subjects, etc.), \
|
||
then make sure those effects are torn down by marking the effect ".cancellable" and \
|
||
returning a corresponding cancellation effect ("Effect.cancel") from another action, \
|
||
or, if your effect is driven by a Combine subject, send it a completion.
|
||
|
||
• If you do not wish to assert on these effects, perform "await \
|
||
store.skipInFlightEffects()", or consider using a non-exhaustive test store: \
|
||
"store.exhaustivity = .off".
|
||
"""
|
||
}
|
||
}
|
||
|
||
func testMultipleChildEffects() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable { var count = 0 }
|
||
enum Action: Equatable {
|
||
case tap
|
||
case response(Int)
|
||
}
|
||
@Dependency(\.mainQueue) var mainQueue
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .tap:
|
||
return .run { [count = state.count] send in
|
||
try await self.mainQueue.sleep(for: .seconds(count))
|
||
await send(.response(42))
|
||
}
|
||
case let .response(value):
|
||
state.count = value
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children: StackState<Child.State>
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }
|
||
.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
|
||
let mainQueue = DispatchQueue.test
|
||
let store = await TestStore(
|
||
initialState: Parent.State(
|
||
children: StackState([
|
||
Child.State(count: 1),
|
||
Child.State(count: 2),
|
||
])
|
||
)
|
||
) {
|
||
Parent()
|
||
} withDependencies: {
|
||
$0.mainQueue = mainQueue.eraseToAnyScheduler()
|
||
}
|
||
|
||
await store.send(.child(.element(id: 0, action: .tap)))
|
||
await store.send(.child(.element(id: 1, action: .tap)))
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.child(.element(id: 0, action: .response(42)))) {
|
||
$0.children[id: 0]?.count = 42
|
||
}
|
||
await mainQueue.advance(by: .seconds(1))
|
||
await store.receive(.child(.element(id: 1, action: .response(42)))) {
|
||
$0.children[id: 1]?.count = 42
|
||
}
|
||
}
|
||
|
||
func testChildEffectCancellation() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable { case tap }
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
.run { _ in try await Task.never() }
|
||
}
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children: StackState<Child.State>
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }
|
||
.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(
|
||
initialState: Parent.State(
|
||
children: StackState([
|
||
Child.State()
|
||
])
|
||
)
|
||
) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.child(.element(id: 0, action: .tap)))
|
||
await store.send(.child(.popFrom(id: 0))) {
|
||
$0.children[id: 0] = nil
|
||
}
|
||
}
|
||
|
||
func testPush() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {}
|
||
var body: some Reducer<State, Action> {
|
||
EmptyReducer()
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
case push
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .child:
|
||
return .none
|
||
case .push:
|
||
state.children.append(Child.State())
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.child(.push(id: 0, state: Child.State()))) {
|
||
$0.children[id: 0] = Child.State()
|
||
}
|
||
await store.send(.push) {
|
||
$0.children[id: 1] = Child.State()
|
||
}
|
||
await store.send(.child(.push(id: 2, state: Child.State()))) {
|
||
$0.children[id: 2] = Child.State()
|
||
}
|
||
await store.send(.push) {
|
||
$0.children[id: 3] = Child.State()
|
||
}
|
||
await store.send(.child(.popFrom(id: 0))) {
|
||
$0.children = StackState()
|
||
}
|
||
await store.send(.child(.push(id: 0, state: Child.State()))) {
|
||
$0.children[id: 0] = Child.State()
|
||
}
|
||
}
|
||
|
||
func testPushReusedID() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {}
|
||
var body: some Reducer<State, Action> {
|
||
EmptyReducer()
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }
|
||
.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
let line = #line - 3
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \
|
||
received a "push" action for an element it already contains. …
|
||
|
||
ID:
|
||
#0
|
||
Path IDs:
|
||
[#0]
|
||
"""
|
||
}
|
||
|
||
await store.send(.child(.push(id: 0, state: Child.State()))) {
|
||
$0.children[id: 0] = Child.State()
|
||
}
|
||
await store.send(.child(.push(id: 0, state: Child.State())))
|
||
}
|
||
|
||
func testPushIDGreaterThanNextGeneration() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {}
|
||
var body: some Reducer<State, Action> {
|
||
EmptyReducer()
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }
|
||
.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
let line = #line - 3
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - A "forEach" at "ComposableArchitectureTests/StackReducerTests.swift:\(line)" \
|
||
received a "push" action with an unexpected generational ID. …
|
||
|
||
Received ID:
|
||
#1
|
||
Expected ID:
|
||
#0
|
||
"""
|
||
}
|
||
|
||
await store.send(.child(.push(id: 1, state: Child.State()))) {
|
||
$0.children[id: 1] = Child.State()
|
||
}
|
||
}
|
||
|
||
func testMismatchedIDFailure() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable {}
|
||
var body: some Reducer<State, Action> {
|
||
EmptyReducer()
|
||
}
|
||
}
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case child(StackActionOf<Child>)
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { _, _ in .none }.forEach(\.children, action: /Action.child) { Child() }
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
XCTExpectFailure {
|
||
$0.compactDescription == """
|
||
failed - A state change does not match expectation: …
|
||
|
||
StackReducerTests.Parent.State(
|
||
children: [
|
||
− #1: StackReducerTests.Child.State()
|
||
+ #0: StackReducerTests.Child.State()
|
||
]
|
||
)
|
||
|
||
(Expected: −, Actual: +)
|
||
"""
|
||
}
|
||
await store.send(.child(.push(id: 0, state: Child.State()))) {
|
||
$0.children[id: 1] = Child.State()
|
||
}
|
||
}
|
||
|
||
func testSendCopiesStackElementIDGenerator() async {
|
||
struct Feature: Reducer {
|
||
struct State: Equatable {
|
||
var path = StackState<Int>()
|
||
}
|
||
enum Action: Equatable {
|
||
case buttonTapped
|
||
case path(StackAction<Int, Never>)
|
||
case response
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .buttonTapped:
|
||
state.path.append(1)
|
||
return .send(.response)
|
||
case .path:
|
||
return .none
|
||
case .response:
|
||
state.path.append(2)
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.path, action: /Action.path) {}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Feature.State()) {
|
||
Feature()
|
||
}
|
||
|
||
await store.send(.buttonTapped) {
|
||
$0.path[id: 0] = 1
|
||
@Dependency(\.stackElementID) var stackElementID
|
||
_ = stackElementID.next()
|
||
_ = stackElementID.next()
|
||
_ = stackElementID.next()
|
||
}
|
||
await store.receive(.response) {
|
||
$0.path[id: 1] = 2
|
||
@Dependency(\.stackElementID) var stackElementID
|
||
_ = stackElementID.next()
|
||
_ = stackElementID.next()
|
||
_ = stackElementID.next()
|
||
}
|
||
await store.send(.buttonTapped) {
|
||
$0.path[id: 2] = 1
|
||
}
|
||
await store.receive(.response) {
|
||
$0.path[id: 3] = 2
|
||
}
|
||
}
|
||
|
||
func testOuterCancellation() async {
|
||
struct Child: Reducer {
|
||
struct State: Equatable {}
|
||
enum Action: Equatable { case onAppear }
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
.run { _ in
|
||
try await Task.never()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct Parent: Reducer {
|
||
struct State: Equatable {
|
||
var children = StackState<Child.State>()
|
||
}
|
||
enum Action: Equatable {
|
||
case children(StackActionOf<Child>)
|
||
case tapAfter
|
||
case tapBefore
|
||
}
|
||
var body: some ReducerOf<Self> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .tapAfter:
|
||
return .none
|
||
case .tapBefore:
|
||
state.children.removeAll()
|
||
return .none
|
||
}
|
||
}
|
||
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .tapAfter:
|
||
return .none
|
||
case .tapBefore:
|
||
return .none
|
||
}
|
||
}
|
||
.forEach(\.children, action: /Action.children) {
|
||
Child()
|
||
}
|
||
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .children:
|
||
return .none
|
||
case .tapAfter:
|
||
state.children.removeAll()
|
||
return .none
|
||
case .tapBefore:
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let store = await TestStore(initialState: Parent.State()) {
|
||
Parent()
|
||
}
|
||
|
||
await store.send(.children(.push(id: 0, state: Child.State()))) {
|
||
$0.children[id: 0] = Child.State()
|
||
}
|
||
await store.send(.children(.element(id: 0, action: .onAppear)))
|
||
await store.send(.tapBefore) {
|
||
$0.children.removeAll()
|
||
}
|
||
|
||
await store.send(.children(.push(id: 1, state: Child.State()))) {
|
||
$0.children[id: 1] = Child.State()
|
||
}
|
||
await store.send(.children(.element(id: 1, action: .onAppear)))
|
||
await store.send(.tapAfter) {
|
||
$0.children.removeAll()
|
||
}
|
||
// NB: Another action needs to come into the `ifLet` to cancel the child action
|
||
await store.send(.tapAfter)
|
||
}
|
||
}
|