Skip to content

Commit 5b242b5

Browse files
mbrandonwstephencelis
authored andcommitted
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 fc4c90c commit 5b242b5

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
@@ -83,22 +83,30 @@ extension Effect where Error == Never {
8383
}
8484

8585
func debugCaseOutput(_ value: Any) -> String {
86-
let mirror = Mirror(reflecting: value)
87-
switch mirror.displayStyle {
88-
case .enum:
89-
guard let child = mirror.children.first else {
90-
let childOutput = "\(value)"
91-
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
92-
}
93-
let childOutput = debugCaseOutput(child.value)
94-
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
95-
case .tuple:
96-
return mirror.children.map { label, value in
97-
let childOutput = debugCaseOutput(value)
98-
return "\(label.map { "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
86+
func debugCaseOutputHelp(_ value: Any) -> String {
87+
let mirror = Mirror(reflecting: value)
88+
switch mirror.displayStyle {
89+
case .enum:
90+
guard let child = mirror.children.first else {
91+
let childOutput = "\(value)"
92+
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
93+
}
94+
let childOutput = debugCaseOutputHelp(child.value)
95+
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
96+
case .tuple:
97+
return mirror.children.map { label, value in
98+
let childOutput = debugCaseOutputHelp(value)
99+
return "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
100+
}
101+
.joined(separator: ", ")
102+
default:
103+
return ""
99104
}
100-
.joined(separator: ", ")
101-
default:
102-
return ""
103105
}
106+
107+
return "\(type(of: value))\(debugCaseOutputHelp(value))"
108+
}
109+
110+
private func isUnlabeledArgument(_ label: String) -> Bool {
111+
label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil
104112
}

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
@@ -150,6 +150,56 @@ final class ReducerTests: XCTestCase {
150150
)
151151
}
152152

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

0 commit comments

Comments
 (0)