Skip to content

Commit 2f92610

Browse files
Specify action formatting when using .debug (#187)
* Customize action formatting when debugging reducers. * wip * wip * include type * wip * tests * docs * fix tests Co-authored-by: Stephen Celis <[email protected]>
1 parent 2e2f466 commit 2f92610

File tree

5 files changed

+221
-22
lines changed

5 files changed

+221
-22
lines changed

Examples/Todos/Todos/Todos.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
131131
environment: { _ in TodoEnvironment() }
132132
)
133133
)
134-
.debug()
134+
135+
.debugActions(actionFormat: .labelsOnly)
135136

136137
struct AppView: View {
137138
struct ViewState: Equatable {

Sources/ComposableArchitecture/Debugging/ReducerDebugging.swift

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
import CasePaths
22
import Dispatch
33

4+
/// Determines how the string description of an action should be printed when using the `.debug()`
5+
/// higher-order reducer.
6+
public enum ActionFormat {
7+
/// Prints the action in a single line by only specifying the labels of the associated values:
8+
///
9+
/// Action.screenA(.row(index:, action: .textChanged(query:)))
10+
case labelsOnly
11+
/// Prints the action in a multiline, pretty-printed format, including all the labels of
12+
/// any associated values, as well as the data held in the associated values:
13+
///
14+
/// Action.screenA(
15+
/// ScreenA.row(
16+
/// index: 1,
17+
/// action: RowAction.textChanged(
18+
/// query: "Hi"
19+
/// )
20+
/// )
21+
/// )
22+
case prettyPrint
23+
}
24+
425
extension Reducer {
526
/// Prints debug messages describing all received actions and state mutations.
627
///
@@ -15,11 +36,18 @@ extension Reducer {
1536
/// - Returns: A reducer that prints debug messages for all received actions.
1637
public func debug(
1738
_ prefix: String = "",
39+
actionFormat: ActionFormat = .prettyPrint,
1840
environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in
1941
DebugEnvironment()
2042
}
2143
) -> Reducer {
22-
self.debug(prefix, state: { $0 }, action: .self, environment: toDebugEnvironment)
44+
self.debug(
45+
prefix,
46+
state: { $0 },
47+
action: .self,
48+
actionFormat: actionFormat,
49+
environment: toDebugEnvironment
50+
)
2351
}
2452

2553
/// Prints debug messages describing all received actions.
@@ -35,11 +63,18 @@ extension Reducer {
3563
/// - Returns: A reducer that prints debug messages for all received actions.
3664
public func debugActions(
3765
_ prefix: String = "",
66+
actionFormat: ActionFormat = .prettyPrint,
3867
environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in
3968
DebugEnvironment()
4069
}
4170
) -> Reducer {
42-
self.debug(prefix, state: { _ in () }, action: .self, environment: toDebugEnvironment)
71+
self.debug(
72+
prefix,
73+
state: { _ in () },
74+
action: .self,
75+
actionFormat: actionFormat,
76+
environment: toDebugEnvironment
77+
)
4378
}
4479

4580
/// Prints debug messages describing all received local actions and local state mutations.
@@ -59,6 +94,7 @@ extension Reducer {
5994
_ prefix: String = "",
6095
state toLocalState: @escaping (State) -> LocalState,
6196
action toLocalAction: CasePath<Action, LocalAction>,
97+
actionFormat: ActionFormat = .prettyPrint,
6298
environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in
6399
DebugEnvironment()
64100
}
@@ -73,9 +109,12 @@ extension Reducer {
73109
return .concatenate(
74110
.fireAndForget {
75111
debugEnvironment.queue.async {
76-
let actionOutput = debugOutput(localAction).indent(by: 2)
77-
let stateOutput =
78-
debugDiff(previousState, nextState).map { "\($0)\n" } ?? " (No state changes)\n"
112+
let actionOutput = actionFormat == .prettyPrint
113+
? debugOutput(localAction).indent(by: 2)
114+
: debugCaseOutput(localAction).indent(by: 2)
115+
let stateOutput = LocalState.self == Void.self
116+
? ""
117+
: debugDiff(previousState, nextState).map { "\($0)\n" } ?? " (No state changes)\n"
79118
debugEnvironment.printer(
80119
"""
81120
\(prefix.isEmpty ? "" : "\(prefix): ")received action:

Sources/ComposableArchitecture/Debugging/ReducerInstrumentation.swift

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,22 +87,30 @@ extension Publisher where Failure == Never {
8787
}
8888

8989
func debugCaseOutput(_ value: Any) -> String {
90-
let mirror = Mirror(reflecting: value)
91-
switch mirror.displayStyle {
92-
case .enum:
93-
guard let child = mirror.children.first else {
94-
let childOutput = "\(value)"
95-
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
96-
}
97-
let childOutput = debugCaseOutput(child.value)
98-
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
99-
case .tuple:
100-
return mirror.children.map { label, value in
101-
let childOutput = debugCaseOutput(value)
102-
return "\(label.map { "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
90+
func debugCaseOutputHelp(_ value: Any) -> String {
91+
let mirror = Mirror(reflecting: value)
92+
switch mirror.displayStyle {
93+
case .enum:
94+
guard let child = mirror.children.first else {
95+
let childOutput = "\(value)"
96+
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
97+
}
98+
let childOutput = debugCaseOutputHelp(child.value)
99+
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
100+
case .tuple:
101+
return mirror.children.map { label, value in
102+
let childOutput = debugCaseOutputHelp(value)
103+
return "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
104+
}
105+
.joined(separator: ", ")
106+
default:
107+
return ""
103108
}
104-
.joined(separator: ", ")
105-
default:
106-
return ""
107109
}
110+
111+
return "\(type(of: value))\(debugCaseOutputHelp(value))"
112+
}
113+
114+
private func isUnlabeledArgument(_ label: String) -> Bool {
115+
label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil
108116
}

Tests/ComposableArchitectureTests/DebugTests.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,4 +832,105 @@ final class DebugTests: XCTestCase {
832832
"""
833833
)
834834
}
835+
836+
func testDebugCaseOutput() {
837+
enum Action {
838+
case action1(Bool, label: String)
839+
case action2(Bool, Int, String)
840+
case screenA(ScreenA)
841+
842+
enum ScreenA {
843+
case row(index: Int, action: RowAction)
844+
845+
enum RowAction {
846+
case tapped
847+
case textChanged(query: String)
848+
}
849+
}
850+
}
851+
852+
XCTAssertEqual(
853+
debugCaseOutput(Action.action1(true, label: "Blob")),
854+
"Action.action1(_:, label:)"
855+
)
856+
857+
XCTAssertEqual(
858+
debugCaseOutput(Action.action2(true, 1, "Blob")),
859+
"Action.action2(_:, _:, _:)"
860+
)
861+
862+
XCTAssertEqual(
863+
debugCaseOutput(Action.screenA(.row(index: 1, action: .tapped))),
864+
"Action.screenA(.row(index:, action: .tapped))"
865+
)
866+
867+
XCTAssertEqual(
868+
debugCaseOutput(Action.screenA(.row(index: 1, action: .textChanged(query: "Hi")))),
869+
"Action.screenA(.row(index:, action: .textChanged(query:)))"
870+
)
871+
}
872+
873+
func testDebugOutput() {
874+
enum Action {
875+
case action1(Bool, label: String)
876+
case action2(Bool, Int, String)
877+
case screenA(ScreenA)
878+
879+
enum ScreenA {
880+
case row(index: Int, action: RowAction)
881+
882+
enum RowAction {
883+
case tapped
884+
case textChanged(query: String)
885+
}
886+
}
887+
}
888+
889+
XCTAssertEqual(
890+
debugOutput(Action.action1(true, label: "Blob")),
891+
"""
892+
Action.action1(
893+
true,
894+
label: "Blob"
895+
)
896+
"""
897+
)
898+
899+
XCTAssertEqual(
900+
debugOutput(Action.action2(true, 1, "Blob")),
901+
"""
902+
Action.action2(
903+
true,
904+
1,
905+
"Blob"
906+
)
907+
"""
908+
)
909+
910+
XCTAssertEqual(
911+
debugOutput(Action.screenA(.row(index: 1, action: .tapped))),
912+
"""
913+
Action.screenA(
914+
ScreenA.row(
915+
index: 1,
916+
action: RowAction.tapped
917+
)
918+
)
919+
"""
920+
)
921+
922+
XCTAssertEqual(
923+
debugOutput(Action.screenA(.row(index: 1, action: .textChanged(query: "Hi")))),
924+
"""
925+
Action.screenA(
926+
ScreenA.row(
927+
index: 1,
928+
action: RowAction.textChanged(
929+
query: "Hi"
930+
)
931+
)
932+
)
933+
"""
934+
)
935+
}
835936
}

Tests/ComposableArchitectureTests/ReducerTests.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,56 @@ final class ReducerTests: XCTestCase {
157157
)
158158
}
159159

160+
func testDebug_ActionFormat_OnlyLabels() {
161+
enum Action: Equatable { case incr(Bool) }
162+
struct State: Equatable { var count = 0 }
163+
164+
var logs: [String] = []
165+
let logsExpectation = self.expectation(description: "logs")
166+
167+
let reducer = Reducer<State, Action, Void> { state, action, _ in
168+
switch action {
169+
case let .incr(bool):
170+
state.count += bool ? 1 : 0
171+
return .none
172+
}
173+
}
174+
.debug("[prefix]", actionFormat: .labelsOnly) { _ in
175+
DebugEnvironment(
176+
printer: {
177+
logs.append($0)
178+
logsExpectation.fulfill()
179+
}
180+
)
181+
}
182+
183+
let viewStore = ViewStore(
184+
Store(
185+
initialState: State(),
186+
reducer: reducer,
187+
environment: ()
188+
)
189+
)
190+
viewStore.send(.incr(true))
191+
192+
self.wait(for: [logsExpectation], timeout: 2)
193+
194+
XCTAssertEqual(
195+
logs,
196+
[
197+
#"""
198+
[prefix]: received action:
199+
Action.incr
200+
  State(
201+
− count: 0
202+
+ count: 1
203+
  )
204+
205+
"""#,
206+
]
207+
)
208+
}
209+
160210
func testDefaultSignpost() {
161211
let reducer = Reducer<Int, Void, Void>.empty.signpost(log: .default)
162212
var n = 0

0 commit comments

Comments
 (0)