Skip to content

Add StateMutationSink #167

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Workflow/Sources/StateMutationSink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright 2022 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Foundation

extension RenderContext {
/// Creates `StateMutationSink`.
///
/// To create a sink:
/// ```
/// let stateMutationSink = context.makeStateMutationSink()
/// ```
///
/// To mutate `State` on an event:
/// ```
/// stateMutationSink.send(\State.value, value: 10)
/// ```
public func makeStateMutationSink() -> StateMutationSink<WorkflowType> {
let sink = makeSink(of: AnyWorkflowAction<WorkflowType>.self)
return StateMutationSink(sink)
}
}

/// StateMutationSink provides a `Sink` that helps mutate `State` using it's `KeyPath`.
public struct StateMutationSink<WorkflowType: Workflow> {
let sink: Sink<AnyWorkflowAction<WorkflowType>>

/// Sends message to `StateMutationSink` to update `State`'s value using the provided closure.
///
/// - Parameters:
/// - update: The `State`` mutation to perform.
public func send(_ update: @escaping (inout WorkflowType.State) -> Void) {
sink.send(
AnyWorkflowAction<WorkflowType> { state in
update(&state)
return nil
}
)
}

/// Sends message to `StateMutationSink` to update `State`'s value at `KeyPath` with `Value`.
///
/// - Parameters:
/// - keyPath: Key path of `State` whose value needs to be mutated.
/// - value: Value to update `State` with.
public func send<Value>(_ keyPath: WritableKeyPath<WorkflowType.State, Value>, value: Value) {
send { $0[keyPath: keyPath] = value }
}

init(_ sink: Sink<AnyWorkflowAction<WorkflowType>>) {
self.sink = sink
}
}
92 changes: 92 additions & 0 deletions Workflow/Tests/StateMutationSinkTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2022 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import ReactiveSwift
import Workflow
import XCTest

final class StateMutationSinkTests: XCTestCase {
var output: Signal<Int, Never>!
var input: Signal<Int, Never>.Observer!

override func setUp() {
(output, input) = Signal<Int, Never>.pipe()
}

func test_initialValue() {
let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output))
XCTAssertEqual(0, host.rendering.value)
}

func test_singleUpdate() {
let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output))

let gotValueExpectation = expectation(description: "Got expected value")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't really affect the semantics of the tests at all, but i think the expectation machinery isn't strictly needed in this instance. the processing should happen synchronously as soon as the values are received.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you prefer that I try removing it?

I just directly ported the test from ios-register and didn't dig into how I might be able to improve these tests much...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, no it's fine as-is, just noticed this. i didn't realize initially that this code was just being moved.

host.rendering.producer.startWithValues { val in
if val == 100 {
gotValueExpectation.fulfill()
}
}

input.send(value: 100)
waitForExpectations(timeout: 1, handler: nil)
}

func test_multipleUpdates() {
let host = WorkflowHost(workflow: TestWorkflow(value: 100, signal: output))

let gotValueExpectation = expectation(description: "Got expected value")

var values: [Int] = []
host.rendering.producer.startWithValues { val in
values.append(val)
if val == 300 {
gotValueExpectation.fulfill()
}
}

input.send(value: 100)
input.send(value: 200)
input.send(value: 300)
XCTAssertEqual(values, [0, 100, 200, 300])
waitForExpectations(timeout: 1, handler: nil)
}

fileprivate struct TestWorkflow: Workflow {
typealias State = Int
typealias Rendering = Int

let value: Int
let signal: Signal<Int, Never>

func makeInitialState() -> Int {
0
}

func render(state: State, context: RenderContext<TestWorkflow>) -> Rendering {
let stateMutationSink = context.makeStateMutationSink()
context.runSideEffect(key: "") { lifetime in
let disposable = signal.observeValues { val in
stateMutationSink.send(\State.self, value: val)
}
lifetime.onEnded {
disposable?.dispose()
}
}
return state
}
}
}