Skip to content

Commit

Permalink
Specify action formatting when using .debug (pointfreeco#187)
Browse files Browse the repository at this point in the history
* Customize action formatting when debugging reducers.

* wip

* wip

* include type

* wip

* tests

* docs

* fix tests

Co-authored-by: Stephen Celis <stephen@stephencelis.com>
  • Loading branch information
mbrandonw and stephencelis committed Jun 15, 2020
1 parent 2e2f466 commit 2f92610
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 22 deletions.
3 changes: 2 additions & 1 deletion Examples/Todos/Todos/Todos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
environment: { _ in TodoEnvironment() }
)
)
.debug()

.debugActions(actionFormat: .labelsOnly)

struct AppView: View {
struct ViewState: Equatable {
Expand Down
49 changes: 44 additions & 5 deletions Sources/ComposableArchitecture/Debugging/ReducerDebugging.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import CasePaths
import Dispatch

/// Determines how the string description of an action should be printed when using the `.debug()`
/// higher-order reducer.
public enum ActionFormat {
/// Prints the action in a single line by only specifying the labels of the associated values:
///
/// Action.screenA(.row(index:, action: .textChanged(query:)))
case labelsOnly
/// Prints the action in a multiline, pretty-printed format, including all the labels of
/// any associated values, as well as the data held in the associated values:
///
/// Action.screenA(
/// ScreenA.row(
/// index: 1,
/// action: RowAction.textChanged(
/// query: "Hi"
/// )
/// )
/// )
case prettyPrint
}

extension Reducer {
/// Prints debug messages describing all received actions and state mutations.
///
Expand All @@ -15,11 +36,18 @@ extension Reducer {
/// - Returns: A reducer that prints debug messages for all received actions.
public func debug(
_ prefix: String = "",
actionFormat: ActionFormat = .prettyPrint,
environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in
DebugEnvironment()
}
) -> Reducer {
self.debug(prefix, state: { $0 }, action: .self, environment: toDebugEnvironment)
self.debug(
prefix,
state: { $0 },
action: .self,
actionFormat: actionFormat,
environment: toDebugEnvironment
)
}

/// Prints debug messages describing all received actions.
Expand All @@ -35,11 +63,18 @@ extension Reducer {
/// - Returns: A reducer that prints debug messages for all received actions.
public func debugActions(
_ prefix: String = "",
actionFormat: ActionFormat = .prettyPrint,
environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in
DebugEnvironment()
}
) -> Reducer {
self.debug(prefix, state: { _ in () }, action: .self, environment: toDebugEnvironment)
self.debug(
prefix,
state: { _ in () },
action: .self,
actionFormat: actionFormat,
environment: toDebugEnvironment
)
}

/// Prints debug messages describing all received local actions and local state mutations.
Expand All @@ -59,6 +94,7 @@ extension Reducer {
_ prefix: String = "",
state toLocalState: @escaping (State) -> LocalState,
action toLocalAction: CasePath<Action, LocalAction>,
actionFormat: ActionFormat = .prettyPrint,
environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in
DebugEnvironment()
}
Expand All @@ -73,9 +109,12 @@ extension Reducer {
return .concatenate(
.fireAndForget {
debugEnvironment.queue.async {
let actionOutput = debugOutput(localAction).indent(by: 2)
let stateOutput =
debugDiff(previousState, nextState).map { "\($0)\n" } ?? " (No state changes)\n"
let actionOutput = actionFormat == .prettyPrint
? debugOutput(localAction).indent(by: 2)
: debugCaseOutput(localAction).indent(by: 2)
let stateOutput = LocalState.self == Void.self
? ""
: debugDiff(previousState, nextState).map { "\($0)\n" } ?? " (No state changes)\n"
debugEnvironment.printer(
"""
\(prefix.isEmpty ? "" : "\(prefix): ")received action:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,30 @@ extension Publisher where Failure == Never {
}

func debugCaseOutput(_ value: Any) -> String {
let mirror = Mirror(reflecting: value)
switch mirror.displayStyle {
case .enum:
guard let child = mirror.children.first else {
let childOutput = "\(value)"
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
}
let childOutput = debugCaseOutput(child.value)
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
case .tuple:
return mirror.children.map { label, value in
let childOutput = debugCaseOutput(value)
return "\(label.map { "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
func debugCaseOutputHelp(_ value: Any) -> String {
let mirror = Mirror(reflecting: value)
switch mirror.displayStyle {
case .enum:
guard let child = mirror.children.first else {
let childOutput = "\(value)"
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
}
let childOutput = debugCaseOutputHelp(child.value)
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
case .tuple:
return mirror.children.map { label, value in
let childOutput = debugCaseOutputHelp(value)
return "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
}
.joined(separator: ", ")
default:
return ""
}
.joined(separator: ", ")
default:
return ""
}

return "\(type(of: value))\(debugCaseOutputHelp(value))"
}

private func isUnlabeledArgument(_ label: String) -> Bool {
label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil
}
101 changes: 101 additions & 0 deletions Tests/ComposableArchitectureTests/DebugTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -832,4 +832,105 @@ final class DebugTests: XCTestCase {
"""
)
}

func testDebugCaseOutput() {
enum Action {
case action1(Bool, label: String)
case action2(Bool, Int, String)
case screenA(ScreenA)

enum ScreenA {
case row(index: Int, action: RowAction)

enum RowAction {
case tapped
case textChanged(query: String)
}
}
}

XCTAssertEqual(
debugCaseOutput(Action.action1(true, label: "Blob")),
"Action.action1(_:, label:)"
)

XCTAssertEqual(
debugCaseOutput(Action.action2(true, 1, "Blob")),
"Action.action2(_:, _:, _:)"
)

XCTAssertEqual(
debugCaseOutput(Action.screenA(.row(index: 1, action: .tapped))),
"Action.screenA(.row(index:, action: .tapped))"
)

XCTAssertEqual(
debugCaseOutput(Action.screenA(.row(index: 1, action: .textChanged(query: "Hi")))),
"Action.screenA(.row(index:, action: .textChanged(query:)))"
)
}

func testDebugOutput() {
enum Action {
case action1(Bool, label: String)
case action2(Bool, Int, String)
case screenA(ScreenA)

enum ScreenA {
case row(index: Int, action: RowAction)

enum RowAction {
case tapped
case textChanged(query: String)
}
}
}

XCTAssertEqual(
debugOutput(Action.action1(true, label: "Blob")),
"""
Action.action1(
true,
label: "Blob"
)
"""
)

XCTAssertEqual(
debugOutput(Action.action2(true, 1, "Blob")),
"""
Action.action2(
true,
1,
"Blob"
)
"""
)

XCTAssertEqual(
debugOutput(Action.screenA(.row(index: 1, action: .tapped))),
"""
Action.screenA(
ScreenA.row(
index: 1,
action: RowAction.tapped
)
)
"""
)

XCTAssertEqual(
debugOutput(Action.screenA(.row(index: 1, action: .textChanged(query: "Hi")))),
"""
Action.screenA(
ScreenA.row(
index: 1,
action: RowAction.textChanged(
query: "Hi"
)
)
)
"""
)
}
}
50 changes: 50 additions & 0 deletions Tests/ComposableArchitectureTests/ReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,56 @@ final class ReducerTests: XCTestCase {
)
}

func testDebug_ActionFormat_OnlyLabels() {
enum Action: Equatable { case incr(Bool) }
struct State: Equatable { var count = 0 }

var logs: [String] = []
let logsExpectation = self.expectation(description: "logs")

let reducer = Reducer<State, Action, Void> { state, action, _ in
switch action {
case let .incr(bool):
state.count += bool ? 1 : 0
return .none
}
}
.debug("[prefix]", actionFormat: .labelsOnly) { _ in
DebugEnvironment(
printer: {
logs.append($0)
logsExpectation.fulfill()
}
)
}

let viewStore = ViewStore(
Store(
initialState: State(),
reducer: reducer,
environment: ()
)
)
viewStore.send(.incr(true))

self.wait(for: [logsExpectation], timeout: 2)

XCTAssertEqual(
logs,
[
#"""
[prefix]: received action:
Action.incr
  State(
− count: 0
+ count: 1
  )
"""#,
]
)
}

func testDefaultSignpost() {
let reducer = Reducer<Int, Void, Void>.empty.signpost(log: .default)
var n = 0
Expand Down

0 comments on commit 2f92610

Please sign in to comment.