diff --git a/.gitignore b/.gitignore index 2232a70..fe28688 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Package.resolved~main_0 .build .idea **/.DS_Store +*.xcuserstate diff --git a/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index b467e76..0000000 Binary files a/Examples/Search/Search.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 67b85da..0000000 Binary files a/Examples/SpeechRecognition/SpeechRecognition.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index a89f526..0000000 Binary files a/Examples/SyncUps/SyncUps.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 917722d..0000000 Binary files a/Examples/TicTacToe/TicTacToe.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Examples/Todos/README.md b/Examples/Todos/README.md index 9eb0a53..7e0e706 100644 --- a/Examples/Todos/README.md +++ b/Examples/Todos/README.md @@ -1,6 +1,6 @@ # Todos -This simple todo application built with the Composable Architecture includes a few bells and whistles: +This simple todo application built with the VDStore includes a few bells and whistles: * Filtering and rearranging todo items. * Automatically sort completed todos to the bottom of the list. diff --git a/Examples/Todos/Todos.xcodeproj/project.pbxproj b/Examples/Todos/Todos.xcodeproj/project.pbxproj index 6abe43a..5717afd 100644 --- a/Examples/Todos/Todos.xcodeproj/project.pbxproj +++ b/Examples/Todos/Todos.xcodeproj/project.pbxproj @@ -300,6 +300,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -361,6 +362,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate index f717615..418492f 100644 Binary files a/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate and b/Examples/Todos/Todos.xcodeproj/project.xcworkspace/xcuserdata/danil.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Examples/Todos/Todos/Todos.swift b/Examples/Todos/Todos/Todos.swift index 4055782..4688c72 100644 --- a/Examples/Todos/Todos/Todos.swift +++ b/Examples/Todos/Todos/Todos.swift @@ -72,6 +72,17 @@ extension Store { } } +extension Store { + + var updateOnCompleted: Store { + onChange(of: \.isComplete) { _, _, _ in + Task { + try await di.store(for: Todos.self)?.todoIsCompletedChanged() + } + } + } +} + struct AppView: View { @ViewStore var todos: Todos @@ -90,11 +101,7 @@ struct AppView: View { List { ForEach(todos.filteredTodos) { todo in TodoView( - store: $todos.todos[id: todo.id].or(todo).onChange(of: \.isComplete) { _, _, _ in - Task { - try await $todos.todoIsCompletedChanged() - } - } + store: $todos.todos[id: todo.id].or(todo).updateOnCompleted ) } .onDelete { $todos.delete(indexSet: $0) } diff --git a/Examples/Todos/TodosTests/TodosTests.swift b/Examples/Todos/TodosTests/TodosTests.swift index 828b2d8..6681e7b 100644 --- a/Examples/Todos/TodosTests/TodosTests.swift +++ b/Examples/Todos/TodosTests/TodosTests.swift @@ -1,51 +1,54 @@ +import Clocks +import IdentifiedCollections import VDStore import XCTest @testable import Todos final class TodosTests: XCTestCase { + let clock = TestClock() @MainActor func testAddTodo() async { - let store = TestStore(initialState: Todos.State()) { - Todos() - } withDependencies: { - $0.uuid = .incrementing - } - - await store.send(.addTodoButtonTapped) { - $0.todos.insert( - Todo.State( + let store = Store(Todos()).di(\.uuid, .incrementing) + + store.addTodoButtonTapped() + XCTAssertEqual( + store.state.todos, + [ + Todo( description: "", id: UUID(0), isComplete: false ), - at: 0 - ) - } + ] + ) + + store.addTodoButtonTapped() - await store.send(.addTodoButtonTapped) { - $0.todos = [ - Todo.State( + XCTAssertEqual( + store.state.todos, + [ + Todo( description: "", id: UUID(1), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), ] - } + ) } @MainActor func testEditTodo() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false @@ -53,60 +56,64 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } + let store = Store(state) + store.state.todos[id: UUID(0)]?.description = "Learn VDStore" - await store.send(\.todos[id: UUID(0)].binding.description, "Learn Composable Architecture") { - $0.todos[id: UUID(0)]?.description = "Learn Composable Architecture" - } - } - - @MainActor - func testCompleteTodo() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", + XCTAssertEqual( + store.state.todos, + [ + Todo( + description: "Learn VDStore", id: UUID(0), isComplete: false ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), ] ) + } - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - } - - await store.send(\.todos[id: UUID(0)].binding.isComplete, true) { - $0.todos[id: UUID(0)]?.isComplete = true - } + @MainActor + func testCompleteTodo() async throws { + let todos: IdentifiedArrayOf = [ + Todo( + description: "", + id: UUID(0), + isComplete: false + ), + Todo( + description: "", + id: UUID(1), + isComplete: false + ), + ] + let state = Todos(todos: todos) + + let middleware = TestMiddleware() + let store = Store(state) + .di(\.continuousClock, clock) + .middleware(middleware) + + let itemStore = store.todos[id: UUID(0)].or(.mock).updateOnCompleted + + itemStore.state.isComplete = true await clock.advance(by: .seconds(1)) - await store.receive(\.sortCompletedTodos) { - $0.todos = [ - $0.todos[1], - $0.todos[0], - ] - } + + try await middleware.waitExecution(of: Store.sortCompletedTodos) + XCTAssertEqual( + store.state.todos, + [todos[1], todos[0]] + ) } @MainActor func testCompleteTodoDebounces() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: false @@ -114,33 +121,35 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - } + let middleware = TestMiddleware() + let store = Store(Todos()) + .di(\.continuousClock, clock) + .middleware(middleware) + + let itemStore = store.todos[id: UUID(0)].or(.mock).updateOnCompleted + + itemStore.state.isComplete = true + XCTAssertTrue(itemStore.state.isComplete) - await store.send(\.todos[id: UUID(0)].binding.isComplete, true) { - $0.todos[id: UUID(0)]?.isComplete = true - } await clock.advance(by: .milliseconds(500)) - await store.send(\.todos[id: UUID(0)].binding.isComplete, false) { - $0.todos[id: UUID(0)]?.isComplete = false - } + + itemStore.state.isComplete = false + XCTAssertFalse(itemStore.state.isComplete) + await clock.advance(by: .seconds(1)) - await store.receive(\.sortCompletedTodos) + middleware.didCall(Store.sortCompletedTodos) } @MainActor func testClearCompleted() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: true @@ -148,32 +157,29 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(.clearCompletedButtonTapped) { - $0.todos = [ - $0.todos[0], - ] - } + let store = Store(Todos()) + store.clearCompletedButtonTapped() + XCTAssertEqual( + store.state.todos, + [state.todos[0]] + ) } @MainActor func testDelete() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(2), isComplete: false @@ -181,34 +187,30 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(.delete([1])) { - $0.todos = [ - $0.todos[0], - $0.todos[2], - ] - } + let store = Store(Todos()) + store.delete(indexSet: [1]) + XCTAssertEqual( + store.state.todos, + [state.todos[0], state.todos[2]] + ) } @MainActor func testDeleteWhileFiltered() async { - let state = Todos.State( + let state = Todos( filter: .completed, todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(2), isComplete: true @@ -216,33 +218,29 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(.delete([0])) { - $0.todos = [ - $0.todos[0], - $0.todos[1], - ] - } + let store = Store(Todos()) + store.delete(indexSet: [0]) + XCTAssertEqual( + store.state.todos, + [state.todos[1], state.todos[2]] + ) } @MainActor func testEditModeMoving() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(2), isComplete: false @@ -250,46 +248,43 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - } - - await store.send(\.binding.editMode, .active) { - $0.editMode = .active - } - await store.send(.move([0], 2)) { - $0.todos = [ - $0.todos[1], - $0.todos[0], - $0.todos[2], - ] - } + let middleware = TestMiddleware() + let store = Store(Todos()) + .di(\.continuousClock, clock) + .middleware(middleware) + + store.state.editMode = .active + XCTAssertEqual(store.state.editMode, .active) + store.move(source: [0], destination: 2) + + XCTAssertEqual( + store.state.todos, + [state.todos[1], state.todos[0], state.todos[2]] + ) await clock.advance(by: .milliseconds(100)) - await store.receive(\.sortCompletedTodos) + middleware.didCall(Store.sortCompletedTodos) } @MainActor func testEditModeMovingWithFilter() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(2), isComplete: true ), - Todo.State( + Todo( description: "", id: UUID(3), isComplete: true @@ -297,41 +292,37 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - $0.uuid = .incrementing - } - - await store.send(\.binding.editMode, .active) { - $0.editMode = .active - } - await store.send(\.binding.filter, .completed) { - $0.filter = .completed - } - await store.send(.move([0], 2)) { - $0.todos = [ - $0.todos[0], - $0.todos[1], - $0.todos[3], - $0.todos[2], - ] - } + let middleware = TestMiddleware() + let store = Store(Todos()) + .di(\.continuousClock, clock) + .di(\.uuid, .incrementing) + .middleware(middleware) + + store.state.editMode = .active + XCTAssertEqual(store.state.editMode, .active) + store.state.filter = .completed + XCTAssertEqual(store.state.filter, .completed) + + store.move(source: [0], destination: 2) + + XCTAssertEqual( + store.state.todos, + [state.todos[0], state.todos[1], state.todos[3], state.todos[2]] + ) await clock.advance(by: .milliseconds(100)) - await store.receive(\.sortCompletedTodos) + middleware.didCall(Store.sortCompletedTodos) } @MainActor func testFilteredEdit() async { - let state = Todos.State( + let state = Todos( todos: [ - Todo.State( + Todo( description: "", id: UUID(0), isComplete: false ), - Todo.State( + Todo( description: "", id: UUID(1), isComplete: true @@ -339,15 +330,12 @@ final class TodosTests: XCTestCase { ] ) - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(\.binding.filter, .completed) { - $0.filter = .completed - } - await store.send(\.todos[id: UUID(1)].binding.description, "Did this already") { - $0.todos[id: UUID(1)]?.description = "Did this already" - } + let store = Store(Todos()) + store.state.filter = .completed + store.state.todos[id: UUID(1)]?.description = "Did this already" + XCTAssertEqual( + store.state.todos[id: UUID(1)]?.description, + "Did this already" + ) } } diff --git a/README.md b/README.md index 24f75a4..00b8bd4 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.33.0") + .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.34.0") ], targets: [ .target(name: "SomeProject", dependencies: ["VDStore"]) diff --git a/Sources/VDStore/Action.swift b/Sources/VDStore/Action.swift index 06671f0..2046cb8 100644 --- a/Sources/VDStore/Action.swift +++ b/Sources/VDStore/Action.swift @@ -3,15 +3,15 @@ import Foundation public extension Store { /// Wrapper around methods that modify the state. Actions are generated by `@Actions` macro. - struct Action: Identifiable { + struct Action: Identifiable, Sendable { public let id: StoreActionID public var name: String { id.name } - let action: @MainActor (Store, Args) -> Res + let action: @Sendable @MainActor (Store, Args) -> Res public init( id: StoreActionID, - action: @escaping @MainActor (Store, Args) -> Res + action: @escaping @Sendable @MainActor (Store, Args) -> Res ) { self.id = id self.action = action @@ -160,13 +160,21 @@ public extension Store { line: UInt, from function: String ) throws -> Res { - try execute( - action, - with: args, - file: file, - line: line, - from: function - ) + let closure = { + action.action(self, $0) + } + return try di.middlewares.executeThrows( + args, + context: Store.Action.Throws.Context( + actionID: action.id, + file: file, + line: line, + function: function + ), + dependencies: di + ) { args in + closure(args) + } .get() } @@ -178,13 +186,21 @@ public extension Store { line: UInt, from function: String ) async -> Res { - await execute( - action, - with: args, - file: file, - line: line, - from: function - ) + let closure = { + action.action(self, $0) + } + return await di.middlewares.executeAsync( + args, + context: Store.Action.Async.Context( + actionID: action.id, + file: file, + line: line, + function: function + ), + dependencies: di + ) { args in + closure(args) + } .value } @@ -196,13 +212,21 @@ public extension Store { line: UInt, from function: String ) async throws -> Res { - try await execute( - action, - with: args, - file: file, - line: line, - from: function - ) + let closure = { + action.action(self, $0) + } + return try await di.middlewares.executeAsyncThrows( + args, + context: Store.Action.AsyncThrows.Context( + actionID: action.id, + file: file, + line: line, + function: function + ), + dependencies: di + ) { args in + closure(args) + } .value } @@ -300,7 +324,7 @@ public extension Store { } /// Store action identifier. -public struct StoreActionID: Hashable, CustomStringConvertible { +public struct StoreActionID: Hashable, CustomStringConvertible, Sendable { /// Action name. public let name: String diff --git a/Sources/VDStore/Middleware.swift b/Sources/VDStore/Middleware.swift index 37a0841..d00d390 100644 --- a/Sources/VDStore/Middleware.swift +++ b/Sources/VDStore/Middleware.swift @@ -11,6 +11,63 @@ public protocol StoreMiddleware { dependencies: StoreDIValues, next: (Args) -> Res ) -> Res + + /// Executes the throwing action with given arguments and context. + func executeThrows( + _ args: Args, + context: Store.Action.Throws.Context, + dependencies: StoreDIValues, + next: (Args) -> Result + ) -> Result + + /// Executes the async action with given arguments and context. + func executeAsync( + _ args: Args, + context: Store.Action.Async.Context, + dependencies: StoreDIValues, + next: (Args) -> Task + ) -> Task + + /// Executes the throwing async action with given arguments and context. + func executeAsyncThrows( + _ args: Args, + context: Store.Action.AsyncThrows.Context, + dependencies: StoreDIValues, + next: (Args) -> Task + ) -> Task +} + +public extension StoreMiddleware { + + /// Executes the throwing action with given arguments and context. + func executeThrows( + _ args: Args, + context: Store.Action.Throws.Context, + dependencies: StoreDIValues, + next: (Args) -> Result + ) -> Result { + execute(args, context: context, dependencies: dependencies, next: next) + } + + /// Executes the async action with given arguments and context. + func executeAsync( + _ args: Args, + context: Store.Action.Async.Context, + dependencies: StoreDIValues, + next: (Args) -> Task + ) -> Task { + execute(args, context: context, dependencies: dependencies, next: next) + } + + /// Executes the throwing async action with given arguments and context. + func executeAsyncThrows( + _ args: Args, + context: Store.Action.AsyncThrows.Context, + dependencies: StoreDIValues, + next: (Args) -> Task + ) -> Task { + execute(args, context: context, dependencies: dependencies, next: next) + } } public extension Store { @@ -60,4 +117,52 @@ struct Middlewares: StoreMiddleware { } return call(args) } + + func executeThrows( + _ args: Args, + context: Store.Action.Throws.Context, + dependencies: StoreDIValues, + next: (Args) -> Result + ) -> Result { + var call = next + for middleware in middlewares { + let currentCall = call + call = { + middleware.executeThrows($0, context: context, dependencies: dependencies, next: currentCall) + } + } + return call(args) + } + + func executeAsync( + _ args: Args, + context: Store.Action.Async.Context, + dependencies: StoreDIValues, + next: (Args) -> Task + ) -> Task { + var call = next + for middleware in middlewares { + let currentCall = call + call = { + middleware.executeAsync($0, context: context, dependencies: dependencies, next: currentCall) + } + } + return call(args) + } + + func executeAsyncThrows( + _ args: Args, + context: Store.Action.AsyncThrows.Context, + dependencies: StoreDIValues, + next: (Args) -> Task + ) -> Task { + var call = next + for middleware in middlewares { + let currentCall = call + call = { + middleware.executeAsyncThrows($0, context: context, dependencies: dependencies, next: currentCall) + } + } + return call(args) + } } diff --git a/Sources/VDStore/TestMiddlware.swift b/Sources/VDStore/TestMiddlware.swift new file mode 100644 index 0000000..e68c304 --- /dev/null +++ b/Sources/VDStore/TestMiddlware.swift @@ -0,0 +1,192 @@ +//#if canImport(XCTest) +//import Foundation +//import XCTest +// +//public final class TestMiddleware: StoreMiddleware { +// +// private var calledActions: [StoreActionID] = [] +// private var calledActionsContinuations: [StoreActionID: [UUID: CheckedContinuation]] = [:] +// private var executedActions: [(StoreActionID, Error?)] = [] +// private var executedActionsContinuations: [StoreActionID: [UUID: CheckedContinuation]] = [:] +// +// public init() {} +// +// public func execute( +// _ args: Args, +// context: Store.Action.Context, +// dependencies: StoreDIValues, +// next: (Args) -> Res +// ) -> Res { +// didCallAction(context.actionID) +// let result = next(args) +// executedActions.append((context.actionID, nil)) +// return result +// } +// +// public func executeThrows( +// _ args: Args, +// context: Store.Action.Throws.Context, +// dependencies: StoreDIValues, +// next: (Args) -> Result +// ) -> Result { +// didCallAction(context.actionID) +// let result = next(args) +// switch result { +// case .success: +// didExecuteAction(context.actionID, error: nil) +// case let .failure(failure): +// didExecuteAction(context.actionID, error: failure) +// } +// return result +// } +// +// public func executeAsync( +// _ args: Args, +// context: Store.Action.Async.Context, +// dependencies: StoreDIValues, +// next: (Args) -> Task +// ) -> Task where Res: Sendable { +// didCallAction(context.actionID) +// let nextTask = next(args) +// Task { +// _ = await nextTask.value +// self.didExecuteAction(context.actionID, error: nil) +// } +// return nextTask +// } +// +// public func executeAsyncThrows( +// _ args: Args, +// context: Store.Action.AsyncThrows.Context, +// dependencies: StoreDIValues, +// next: (Args) -> Task +// ) -> Task where Res: Sendable { +// didCallAction(context.actionID) +// let nextTask = next(args) +// Task { +// do { +// _ = try await nextTask.value +// self.didExecuteAction(context.actionID, error: nil) +// } catch { +// self.didExecuteAction(context.actionID, error: error) +// } +// } +// return nextTask +// } +// +// public func didExecute( +// _ action: Store.Action, +// file: StaticString = #file, +// line: UInt = #line +// ) throws { +// if let i = executedActions.firstIndex(where: { $0.0 == action.id }) { +// defer { executedActions.remove(at: i) } +// if let error = executedActions[i].1 { +// throw error +// } +// return +// } +// XCTFail("Action \(action.id) was not executed", file: file, line: line) +// } +// +// public func waitExecution( +// of action: Store.Action, +// timeout: TimeInterval = 1, +// file: StaticString = #file, +// line: UInt = #line +// ) async throws { +// if let i = executedActions.firstIndex(where: { $0.0 == action.id }) { +// defer { executedActions.remove(at: i) } +// if let error = executedActions[i].1 { +// throw error +// } +// return +// } +// guard timeout > 0 else { +// XCTFail("Timeout waiting for action \(action.id) to be executed", file: file, line: line) +// return +// } +// try await withThrowingTaskGroup(of: Void.self) { group in +// let uuid = UUID() +// group.addTask { +// try await withCheckedThrowingContinuation { continuation in +// self.executedActionsContinuations[action.id, default: [:]][uuid] = continuation +// } +// } +// group.addTask { +// try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) +// if let continuation = self.executedActionsContinuations[action.id]?[uuid] { +// self.executedActionsContinuations[action.id]?[uuid] = nil +// XCTFail("Timeout waiting for action \(action.id) to be executed", file: file, line: line) +// continuation.resume() +// } +// } +// try await group.waitForAll() +// } +// } +// +// public func didCall( +// _ action: Store.Action, +// file: StaticString = #file, +// line: UInt = #line +// ) { +// if let i = calledActions.firstIndex(of: action.id) { +// calledActions.remove(at: i) +// return +// } +// XCTFail("Action \(action.id) was not called", file: file, line: line) +// } +// +// public func waitCall( +// of action: Store.Action, +// timeout: TimeInterval = 0.1, +// file: StaticString = #file, +// line: UInt = #line +// ) async { +// if let i = calledActions.firstIndex(of: action.id) { +// calledActions.remove(at: i) +// return +// } +// guard timeout > 0 else { +// XCTFail("Timeout waiting for action \(action.id) to be called", file: file, line: line) +// return +// } +// await withTaskGroup(of: Void.self) { group in +// let uuid = UUID() +// group.addTask { +// await withCheckedContinuation { continuation in +// self.calledActionsContinuations[action.id, default: [:]][uuid] = continuation +// } +// } +// group.addTask { +// try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) +// if let continuation = self.calledActionsContinuations[action.id]?[uuid] { +// self.calledActionsContinuations[action.id]?[uuid] = nil +// XCTFail("Timeout waiting for action \(action.id) to be called", file: file, line: line) +// continuation.resume() +// } +// } +// await group.waitForAll() +// } +// } +// +// private func didCallAction(_ actionID: StoreActionID) { +// calledActions.append(actionID) +// calledActionsContinuations[actionID]?.values.forEach { $0.resume() } +// calledActionsContinuations[actionID] = nil +// } +// +// private func didExecuteAction(_ actionID: StoreActionID, error: Error?) { +// executedActions.append((actionID, error)) +// executedActionsContinuations[actionID]?.values.forEach { +// if let error { +// $0.resume(throwing: error) +// } else { +// $0.resume() +// } +// } +// executedActionsContinuations[actionID] = nil +// } +//} +// +//#endif diff --git a/Sources/VDStoreMacros/ActionsMacro.swift b/Sources/VDStoreMacros/ActionsMacro.swift index 544cbba..ec15ee1 100644 --- a/Sources/VDStoreMacros/ActionsMacro.swift +++ b/Sources/VDStoreMacros/ActionsMacro.swift @@ -21,8 +21,6 @@ public struct ActionsMacro: MemberAttributeMacro, MemberMacro { for member in extensionDecl.memberBlock.members { if let function = member.decl.as(FunctionDeclSyntax.self) { result += try VDStoreMacros.expansion(of: node, funcDecl: function, in: context) - } else { - throw CustomError("\(type(of: member))") } } return result @@ -41,6 +39,8 @@ public struct ActionsMacro: MemberAttributeMacro, MemberMacro { guard type.hasPrefix("Store<"), type.hasSuffix(">") else { throw CustomError("@Actions only works on Store extension") } + guard let funcDecl = member.as(FunctionDeclSyntax.self) else { return [] } + guard !funcDecl.modifiers.contains(where: { $0.name.trimmed.description == "static" }) else { return [] } return ["@_disfavoredOverload"] } } @@ -67,8 +67,15 @@ private func expansion( funcDecl: FunctionDeclSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard !funcDecl.modifiers.contains(where: { $0.trimmed.description == "private" }) else { - throw CustomError("Action functions must not be private") + let privateIndex = funcDecl.modifiers.firstIndex(where: { $0.trimmed.description == "private" }) + // if privateIndex == nil { + // context.diagnose(Diagnostic(node: Syntax(funcDecl), message: Feedback( + // .warning, + // "It's recommended to make `\(funcDecl.name.trimmed.text)` private." + // ))) + // } + guard !funcDecl.modifiers.contains(where: { $0.name.trimmed.description == "static" }) else { + return [] } let isAsync = funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil @@ -132,11 +139,12 @@ private func expansion( """) var executeDecl = funcDecl - executeDecl.remove(attribute: "Action") + if let privateIndex { + executeDecl.modifiers.remove(at: privateIndex) + } executeDecl.remove(attribute: "CancelInFlight") executeDecl.remove(attribute: "_disfavoredOverload") - // executeDecl.add(attribute: "MainActor") - // executeDecl.modifiers.remove(at: privateIndex) + var parameterList = executeDecl.signature.parameterClause.parameters.map { FunctionParameterSyntax( leadingTrivia: .newline, diff --git a/Sources/VDStoreMacros/Feedback.swift b/Sources/VDStoreMacros/Feedback.swift index f35b944..211fcd3 100644 --- a/Sources/VDStoreMacros/Feedback.swift +++ b/Sources/VDStoreMacros/Feedback.swift @@ -4,10 +4,6 @@ import SwiftSyntax struct Feedback: DiagnosticMessage { - static let noDefaultArgument = Feedback(.error, "No default value provided.") - static let missingAnnotation = Feedback(.error, "No annotation provided.") - static let notAnIdentifier = Feedback(.error, "Identifier is not valid.") - var message: String var severity: DiagnosticSeverity @@ -17,7 +13,7 @@ struct Feedback: DiagnosticMessage { } var diagnosticID: MessageID { - MessageID(domain: "VDStoreMacros", id: message) + MessageID(domain: "VDStore", id: message) } } #endif diff --git a/Tests/VDStoreTests/VDStoreTests.swift b/Tests/VDStoreTests/VDStoreTests.swift index 55a3f14..e44d27b 100644 --- a/Tests/VDStoreTests/VDStoreTests.swift +++ b/Tests/VDStoreTests/VDStoreTests.swift @@ -63,7 +63,7 @@ final class VDStoreTests: XCTestCase { for i in 0 ..< 10 { guard !Task.isCancelled else { return i } if i == 5 { - await store.cancel(id: id) + store.cancel(id: id) } } return 10