Commit ed3a8318 authored by Simeon Anfinrud's avatar Simeon Anfinrud Committed by Commit Bot

[Chromecast] Restructure reactive unittests.

* Behaviors specific to Controller are in a dedicated test.
* Each operator on Observable gets its own test file.
* Simplify ReactiveRecorder.
  * Rename entered() and exited() to opened() and closed().
  * Only watch one Observable per ReactiveRecorder.
  * Add an unsubscribe() method to test that scopes close.
  * Improve documentation in reactive_java.md.
* Add equals() method to Both so assertions are more useful.
  * Also added a hashCode() method for completeness.
* Use ReactiveRecorder more heavily than ArrayLists.

Bug: None
Test: cast_base_junit_tests, cast_shell_junit_tests
Change-Id: I2edd4627abe18523eb34482c648f8c15ffbfd89e
Reviewed-on: https://chromium-review.googlesource.com/1161459Reviewed-by: default avatarLuke Halliwell <halliwell@chromium.org>
Commit-Queue: Simeon Anfinrud <sanfin@chromium.org>
Cr-Commit-Position: refs/heads/master@{#580723}
parent a39370b6
......@@ -348,9 +348,15 @@ if (is_android) {
java_test_dir = "//chromecast/base/java/test"
java_files = [
"$java_test_dir/org/chromium/chromecast/base/BothTest.java",
"$java_test_dir/org/chromium/chromecast/base/ControllerTest.java",
"$java_test_dir/org/chromium/chromecast/base/CircularBufferTest.java",
"$java_test_dir/org/chromium/chromecast/base/ItertoolsTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableAndControllerTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableAndTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableAndThenTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableFilterTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableMapTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableMiscellaneousTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObservableNotTest.java",
"$java_test_dir/org/chromium/chromecast/base/ReactiveRecorderTest.java",
"$java_test_dir/org/chromium/chromecast/base/ObserversTest.java",
"$java_test_dir/org/chromium/chromecast/base/UnitTest.java",
......
......@@ -4,6 +4,10 @@
package org.chromium.chromecast.base;
import android.annotation.SuppressLint;
import java.util.Objects;
/**
* Represents a structure containing an instance of both A and B.
*
......@@ -45,6 +49,21 @@ public class Both<A, B> {
.toString();
}
@Override
public boolean equals(Object other) {
if (other instanceof Both) {
Both<?, ?> that = (Both<?, ?>) other;
return this.first.equals(that.first) && this.second.equals(that.second);
}
return false;
}
@SuppressLint("NewApi")
@Override
public int hashCode() {
return Objects.hash(this.first, this.second);
}
/**
* Constructs a Both object containing both `a` and `b`.
*/
......
......@@ -49,6 +49,15 @@ public class BothTest {
assertEquals(x.toString(), "a, b");
}
@Test
public void testBothEquals() {
assertTrue(Both.both("a", "b").equals(Both.both("a", "b")));
assertFalse(Both.both("a", "b").equals(Both.both("b", "b")));
assertFalse(Both.both("a", "b").equals(Both.both("a", "a")));
assertFalse(Both.both(1, 2).equals(Both.both("a", "b")));
assertFalse(Both.both("hi", 0).equals(new Object()));
}
@Test
public void testUseGetFirstAsMethodReference() {
Both<Integer, String> x = Both.both(1, "one");
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.assertThat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import java.util.ArrayList;
import java.util.List;
/**
* Tests for behavior specific to Controller.
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ControllerTest {
// Convenience method to create a scope that mutates a list of strings on state transitions.
// When entering the state, it will append "enter ${id} ${data}" to the result list, where
// `data` is the String that is associated with the state activation. When exiting the state,
// it will append "exit ${id}" to the result list. This provides a readable way to track and
// verify the behavior of observers in response to the Observables they are linked to.
public static <T> Observer<T> report(List<String> result, String id) {
// Did you know that lambdas are awesome.
return (T data) -> {
result.add("enter " + id + ": " + data);
return () -> result.add("exit " + id);
};
}
@Test
public void testNoStateTransitionAfterRegisteringWithInactiveController() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
recorder.verify().end();
}
@Test
public void testStateIsopenedWhenControllerIsSet() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
// Activate the state by setting the controller.
controller.set("cool");
recorder.verify().opened("cool").end();
}
@Test
public void testBasicStateFromController() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set("fun");
// Deactivate the state by resetting the controller.
controller.reset();
recorder.verify().opened("fun").closed("fun").end();
}
@Test
public void testSetStateTwicePerformsImplicitReset() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
// Activate the state for the first time.
controller.set("first");
// Activate the state for the second time.
controller.set("second");
// If set() is called without a reset() in-between, the tracking state exits, then re-enters
// with the new data. So we expect to find an "exit" call between the two enter calls.
recorder.verify().opened("first").closed("first").opened("second").end();
}
@Test
public void testResetWhileStateIsNotopenedIsNoOp() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.reset();
recorder.verify().end();
}
@Test
public void testMultipleStatesObservingSingleController() {
// Construct two states that watch the same Controller. Verify both observers' events are
// triggered.
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder1 = ReactiveRecorder.record(controller);
ReactiveRecorder recorder2 = ReactiveRecorder.record(controller);
// Activate the controller, which should propagate a state transition to both states.
// Both states should be updated, so we should get two enter events.
controller.set("neat");
controller.reset();
recorder1.verify().opened("neat").closed("neat").end();
recorder2.verify().opened("neat").closed("neat").end();
}
@Test
public void testNewStateIsActivatedImmediatelyIfObservingAlreadyActiveObservable() {
Controller<String> controller = new Controller<>();
controller.set("surprise");
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
recorder.verify().opened("surprise").end();
}
@Test
public void testNewStateIsNotActivatedIfObservingObservableThatHasBeenDeactivated() {
Controller<String> controller = new Controller<>();
controller.set("surprise");
controller.reset();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
recorder.verify().end();
}
@Test
public void testResetWhileAlreadyDeactivatedIsANoOp() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set("radical");
controller.reset();
// Resetting again after already resetting should not notify the observer.
controller.reset();
recorder.verify().opened("radical").closed("radical").end();
}
@Test
public void testClosedWatchScopeDoesNotGetNotifiedOfFutureActivations() {
Controller<String> a = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a);
a.set("during temp");
a.reset();
recorder.unsubscribe();
a.set("after temp");
recorder.verify().opened("during temp").closed("during temp").end();
}
@Test
public void testClosedWatchScopeIsImplicitlyDeactivated() {
Controller<String> a = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a);
a.set("implicitly reset this");
recorder.unsubscribe();
recorder.verify().opened("implicitly reset this").closed("implicitly reset this").end();
}
@Test
public void testCloseWatchScopeAfterDeactivatingSourceStateDoesNotCallExitHAndlerAgain() {
Controller<String> a = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a);
a.set("and a one");
a.reset();
recorder.unsubscribe();
recorder.verify().opened("and a one").closed("and a one").end();
}
@Test
public void testSetControllerWithNullImplicitlyResets() {
Controller<String> a = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a);
a.set("not null");
a.set(null);
recorder.verify().opened("not null").closed("not null").end();
}
@Test
public void testResetControllerInActivationHandler() {
Controller<String> a = new Controller<>();
List<String> result = new ArrayList<>();
a.watch((String s) -> {
result.add("enter " + s);
a.reset();
result.add("after reset");
return () -> {
result.add("exit");
};
});
a.set("immediately retracted");
assertThat(result, contains("enter immediately retracted", "after reset", "exit"));
}
@Test
public void testSetControllerInActivationHandler() {
Controller<String> a = new Controller<>();
List<String> result = new ArrayList<>();
a.watch(report(result, "weirdness"));
a.watch((String s) -> {
// If the activation handler always calls set() on the source controller, you will have
// an infinite loop, which is not cool. However, if the activation handler only
// conditionally calls set() on its source controller, then the case where set() is not
// called will break the loop. It is the responsibility of the programmer to solve the
// halting problem for activation handlers.
if (s.equals("first")) {
a.set("second");
}
return () -> {
result.add("haha");
};
});
a.set("first");
assertThat(result,
contains("enter weirdness: first", "haha", "exit weirdness",
"enter weirdness: second"));
}
@Test
public void testResetControllerInDeactivationHandler() {
Controller<String> a = new Controller<>();
List<String> result = new ArrayList<>();
a.watch(report(result, "bizzareness"));
a.watch((String s) -> () -> a.reset());
a.set("yo");
a.reset();
// The reset() called by the deactivation handler should be a no-op.
assertThat(result, contains("enter bizzareness: yo", "exit bizzareness"));
}
@Test
public void testSetControllerInDeactivationHandler() {
Controller<String> a = new Controller<>();
List<String> result = new ArrayList<>();
a.watch(report(result, "astoundingness"));
a.watch((String s) -> () -> a.set("never mind"));
a.set("retract this");
a.reset();
// The set() called by the deactivation handler should immediately set the controller back.
assertThat(result,
contains("enter astoundingness: retract this", "exit astoundingness",
"enter astoundingness: never mind"));
}
@Test
public void testSetWithDuplicateValueIsNoOp() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set("stop copying me");
controller.set("stop copying me");
recorder.verify().opened("stop copying me").end();
}
@Test
public void testSetUnitControllerInActivatedStateIsNoOp() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
recorder.verify().opened(Unit.unit()).end();
controller.set(Unit.unit());
recorder.verify().end();
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
/**
* Tests for Observable#and().
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ObservableAndTest {
@Test
public void testBothState_activateFirstDoesNotTrigger() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
a.set("A");
recorder.verify().end();
}
@Test
public void testBothState_activateSecondDoesNotTrigger() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
b.set("B");
recorder.verify().end();
}
@Test
public void testBothState_activateBothTriggers() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
a.set("A");
b.set("B");
recorder.verify().opened(Both.both("A", "B")).end();
}
@Test
public void testBothState_deactivateFirstAfterTrigger() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
a.set("A");
b.set("B");
a.reset();
recorder.verify().opened(Both.both("A", "B")).closed(Both.both("A", "B")).end();
}
@Test
public void testBothState_deactivateSecondAfterTrigger() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
a.set("A");
b.set("B");
b.reset();
recorder.verify().opened(Both.both("A", "B")).closed(Both.both("A", "B")).end();
}
@Test
public void testBothState_resetFirstBeforeSettingSecond_doesNotTrigger() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
a.set("A");
a.reset();
b.set("B");
recorder.verify().end();
}
@Test
public void testBothState_resetSecondBeforeSettingFirst_doesNotTrigger() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
b.set("B");
b.reset();
a.set("A");
recorder.verify().end();
}
@Test
public void testBothState_setOneControllerAfterTrigger_implicitlyResetsAndSets() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b));
a.set("A1");
b.set("B1");
a.set("A2");
b.set("B2");
recorder.verify()
.opened(Both.both("A1", "B1"))
.closed(Both.both("A1", "B1"))
.opened(Both.both("A2", "B1"))
.closed(Both.both("A2", "B1"))
.opened(Both.both("A2", "B2"))
.end();
}
@Test
public void testComposeBoth() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
Controller<String> c = new Controller<>();
Controller<String> d = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a.and(b).and(c).and(d));
a.set("a");
b.set("b");
c.set("c");
d.set("d");
a.reset();
recorder.verify()
.opened(Both.both(Both.both(Both.both("a", "b"), "c"), "d"))
.closed(Both.both(Both.both(Both.both("a", "b"), "c"), "d"))
.end();
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.emptyIterable;
import static org.junit.Assert.assertThat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import java.util.ArrayList;
import java.util.List;
/**
* Tests for Observable#andThen().
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ObservableAndThenTest {
@Test
public void testAndThenNotActivatedInitially() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
List<String> result = new ArrayList<>();
aState.andThen(bState).watch(
Observers.onEnter((String a, String b) -> { result.add("a=" + a + ", b=" + b); }));
assertThat(result, emptyIterable());
}
@Test
public void testAndThenNotActivatedIfSecondBeforeFirst() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
List<String> result = new ArrayList<>();
aState.andThen(bState).watch(
Observers.onEnter((String a, String b) -> { result.add("a=" + a + ", b=" + b); }));
bState.set("b");
aState.set("a");
assertThat(result, emptyIterable());
}
@Test
public void testAndThenActivatedIfFirstThenSecond() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
List<String> result = new ArrayList<>();
aState.andThen(bState).watch(
Observers.onEnter((String a, String b) -> { result.add("a=" + a + ", b=" + b); }));
aState.set("a");
bState.set("b");
assertThat(result, contains("a=a, b=b"));
}
@Test
public void testAndThenActivated_plusBplusAminusBplusB() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
List<String> result = new ArrayList<>();
aState.andThen(bState).watch(
Observers.onEnter((String a, String b) -> { result.add("a=" + a + ", b=" + b); }));
bState.set("b");
aState.set("a");
bState.reset();
bState.set("B");
assertThat(result, contains("a=a, b=B"));
}
@Test
public void testAndThenDeactivated_plusAplusBminusA() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
List<String> result = new ArrayList<>();
aState.andThen(bState).watch(
Observers.onExit((String a, String b) -> { result.add("a=" + a + ", b=" + b); }));
aState.set("A");
bState.set("B");
aState.reset();
assertThat(result, contains("a=A, b=B"));
}
@Test
public void testAndThenDeactivated_plusAplusBminusB() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
List<String> result = new ArrayList<>();
aState.andThen(bState).watch(
Observers.onExit((String a, String b) -> { result.add("a=" + a + ", b=" + b); }));
aState.set("A");
bState.set("B");
bState.reset();
assertThat(result, contains("a=A, b=B"));
}
@Test
public void testComposeAndThen() {
Controller<Unit> aState = new Controller<>();
Controller<Unit> bState = new Controller<>();
Controller<Unit> cState = new Controller<>();
Controller<Unit> dState = new Controller<>();
Observable<Both<Unit, Unit>> aThenB = aState.andThen(bState);
Observable<Both<Both<Unit, Unit>, Unit>> aThenBThenC = aThenB.andThen(cState);
Observable<Both<Both<Both<Unit, Unit>, Unit>, Unit>> aThenBThenCThenD =
aThenBThenC.andThen(dState);
List<String> result = new ArrayList<>();
aState.watch(Observers.onEnter(() -> result.add("A")));
aThenB.watch(Observers.onEnter(() -> result.add("B")));
aThenBThenC.watch(Observers.onEnter(() -> result.add("C")));
aThenBThenCThenD.watch(Observers.onEnter(() -> result.add("D")));
aState.set(Unit.unit());
bState.set(Unit.unit());
cState.set(Unit.unit());
dState.set(Unit.unit());
aState.reset();
assertThat(result, contains("A", "B", "C", "D"));
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
/**
* Tests for Observable#filter().
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ObservableFilterTest {
@Test
public void testFilter() {
Controller<String> a = new Controller<>();
ReactiveRecorder empty = ReactiveRecorder.record(a.filter(String::isEmpty));
ReactiveRecorder startsWithA = ReactiveRecorder.record(a.filter(s -> s.startsWith("a")));
ReactiveRecorder endsWithA = ReactiveRecorder.record(a.filter(s -> s.endsWith("a")));
a.set("");
empty.verify().opened("").end();
startsWithA.verify().end();
endsWithA.verify().end();
a.reset();
empty.verify().closed("").end();
startsWithA.verify().end();
endsWithA.verify().end();
a.set("a");
empty.verify().end();
startsWithA.verify().opened("a").end();
endsWithA.verify().opened("a").end();
a.set("doa");
empty.verify().end();
startsWithA.verify().closed("a").end();
endsWithA.verify().closed("a").opened("doa").end();
a.set("ada");
empty.verify().end();
startsWithA.verify().opened("ada").end();
endsWithA.verify().closed("doa").opened("ada").end();
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.chromium.chromecast.base.Inheritance.Base;
import org.chromium.chromecast.base.Inheritance.Derived;
/**
* Tests for Observable#map().
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ObservableMapTest {
@Test
public void testMapController() {
Controller<String> original = new Controller<>();
Observable<String> lowerCase = original.map(String::toLowerCase);
Observable<String> upperCase = lowerCase.map(String::toUpperCase);
ReactiveRecorder recordOriginal = ReactiveRecorder.record(original);
ReactiveRecorder recordLowerCase = ReactiveRecorder.record(lowerCase);
ReactiveRecorder recordUpperCase = ReactiveRecorder.record(upperCase);
original.set("sImPlY sTeAmEd KaLe");
original.reset();
recordOriginal.verify().opened("sImPlY sTeAmEd KaLe").closed("sImPlY sTeAmEd KaLe").end();
recordLowerCase.verify().opened("simply steamed kale").closed("simply steamed kale").end();
recordUpperCase.verify().opened("SIMPLY STEAMED KALE").closed("SIMPLY STEAMED KALE").end();
}
@Test
public void testMapWithFunctionOfSuper() {
Controller<Derived> a = new Controller<>();
// Compile error if generics are wrong.
Observable<String> r = a.map((Base base) -> base.toString());
ReactiveRecorder recorder = ReactiveRecorder.record(r);
a.set(new Derived());
recorder.verify().opened("Derived").end();
}
@Test
public void testMapReturnSubclassOfResultType() {
Controller<Unit> a = new Controller<>();
Derived d = new Derived();
Function<Unit, Derived> f = x -> d;
// Compile error if generics are wrong.
Observable<Base> r = a.map(f);
ReactiveRecorder recorder = ReactiveRecorder.record(r);
a.set(Unit.unit());
recorder.verify().opened(d).end();
}
@Test
public void testMapDropsNullResult() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller.map(x -> null));
controller.set(Unit.unit());
// Recorder should not get any events because the map function returned null.
recorder.verify().end();
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.assertThat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.chromium.chromecast.base.Inheritance.Base;
import org.chromium.chromecast.base.Inheritance.Derived;
import java.util.ArrayList;
import java.util.List;
/**
* Miscellaneous tests for Observable.
*
* This includes advanced behaviors like watch-currying and correct use of generics.
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ObservableMiscellaneousTest {
@Test
public void testBeingTooCleverWithObserversAndInheritance() {
Controller<Base> baseController = new Controller<>();
Controller<Derived> derivedController = new Controller<>();
List<String> result = new ArrayList<>();
// Test that the same Observer object can observe Observables of different types, as
// long as the Observer type is a superclass of both Observable types.
Observer<Base> observer = (Base value) -> {
result.add("enter: " + value.toString());
return () -> result.add("exit: " + value.toString());
};
baseController.watch(observer);
// Compile error if generics are wrong.
derivedController.watch(observer);
baseController.set(new Base());
// The scope from the previous set() call will not be overridden because this is activating
// a different Controller.
derivedController.set(new Derived());
// The Controller<Base> can be activated with an object that extends Base.
baseController.set(new Derived());
assertThat(
result, contains("enter: Base", "enter: Derived", "exit: Base", "enter: Derived"));
}
@Test
public void testWatchCurrying() {
Controller<String> aState = new Controller<>();
Controller<String> bState = new Controller<>();
Controller<String> result = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(result);
// I guess this makes .and() obsolete?
aState.watch(a -> bState.watch(b -> {
result.set("" + a + ", " + b);
return () -> result.reset();
}));
aState.set("A");
bState.set("B");
recorder.verify().opened("A, B").end();
aState.reset();
recorder.verify().closed("A, B").end();
aState.set("AA");
recorder.verify().opened("AA, B").end();
bState.reset();
recorder.verify().closed("AA, B").end();
}
@Test
public void testPowerUnlimitedPower() {
Controller<Unit> aState = new Controller<>();
Controller<Unit> bState = new Controller<>();
Controller<Unit> cState = new Controller<>();
Controller<Unit> dState = new Controller<>();
List<String> result = new ArrayList<>();
// Praise be to Haskell Curry.
aState.watch(a -> bState.watch(b -> cState.watch(c -> dState.watch(d -> {
result.add("it worked!");
return () -> result.add("exit");
}))));
aState.set(Unit.unit());
bState.set(Unit.unit());
cState.set(Unit.unit());
dState.set(Unit.unit());
assertThat(result, contains("it worked!"));
result.clear();
aState.reset();
assertThat(result, contains("exit"));
result.clear();
aState.set(Unit.unit());
assertThat(result, contains("it worked!"));
result.clear();
bState.reset();
assertThat(result, contains("exit"));
result.clear();
bState.set(Unit.unit());
assertThat(result, contains("it worked!"));
result.clear();
cState.reset();
assertThat(result, contains("exit"));
result.clear();
cState.set(Unit.unit());
assertThat(result, contains("it worked!"));
result.clear();
dState.reset();
assertThat(result, contains("exit"));
result.clear();
dState.set(Unit.unit());
assertThat(result, contains("it worked!"));
}
// Any Scope implementation with a constructor of one argument can use a method reference to its
// constructor as an Observer.
private static class TransitionLogger implements Scope {
public static final List<String> sResult = new ArrayList<>();
private final String mData;
public TransitionLogger(String data) {
mData = data;
sResult.add("enter: " + mData);
}
@Override
public void close() {
sResult.add("exit: " + mData);
}
}
@Test
public void testObserverWithAutoCloseableConstructor() {
Controller<String> controller = new Controller<>();
// You can use a constructor method reference in a watch() call.
controller.watch(TransitionLogger::new);
controller.set("a");
controller.reset();
assertThat(TransitionLogger.sResult, contains("enter: a", "exit: a"));
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromecast.base;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.emptyIterable;
import static org.junit.Assert.assertThat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.BlockJUnit4ClassRunner;
import java.util.ArrayList;
import java.util.List;
/**
* Tests for Observable.not().
*/
@RunWith(BlockJUnit4ClassRunner.class)
public class ObservableNotTest {
@Test
public void testNotIsActivatedAtTheStart() {
Controller<String> invertThis = new Controller<>();
List<String> result = new ArrayList<>();
Observable.not(invertThis).watch(x -> {
result.add("enter inverted");
return () -> result.add("exit inverted");
});
assertThat(result, contains("enter inverted"));
}
@Test
public void testNotIsDeactivatedAtTheStartIfSourceIsAlreadyActivated() {
Controller<String> invertThis = new Controller<>();
List<String> result = new ArrayList<>();
invertThis.set("way ahead of you");
Observable.not(invertThis).watch(x -> {
result.add("enter inverted");
return () -> result.add("exit inverted");
});
assertThat(result, emptyIterable());
}
@Test
public void testNotExitsWhenSourceIsActivated() {
Controller<String> invertThis = new Controller<>();
List<String> result = new ArrayList<>();
Observable.not(invertThis).watch(x -> {
result.add("enter inverted");
return () -> result.add("exit inverted");
});
invertThis.set("first");
assertThat(result, contains("enter inverted", "exit inverted"));
}
@Test
public void testNotReentersWhenSourceIsReset() {
Controller<String> invertThis = new Controller<>();
List<String> result = new ArrayList<>();
Observable.not(invertThis).watch(x -> {
result.add("enter inverted");
return () -> result.add("exit inverted");
});
invertThis.set("first");
invertThis.reset();
assertThat(result, contains("enter inverted", "exit inverted", "enter inverted"));
}
}
......@@ -5,13 +5,12 @@
package org.chromium.chromecast.base;
import static org.hamcrest.Matchers.emptyIterable;
import static org.hamcrest.Matchers.not;
import org.junit.Assert;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Records events emitted by Observables, and provides a fluent interface to perform assertions on
......@@ -19,23 +18,22 @@ import java.util.Map;
*/
public class ReactiveRecorder {
private final List<Event> mRecord;
private final Map<Observable<?>, String> mObservableNames;
private final Scope mSubscription;
public static ReactiveRecorder record(Observable<?>... observables) {
return new ReactiveRecorder(observables);
public static ReactiveRecorder record(Observable<?> observable) {
return new ReactiveRecorder(observable);
}
private ReactiveRecorder(Observable<?>... observables) {
private ReactiveRecorder(Observable<?> observable) {
mRecord = new ArrayList<>();
mObservableNames = new HashMap<>();
int id = 0;
for (Observable<?> observable : observables) {
mObservableNames.put(observable, "Observable" + id++);
observable.watch((Object value) -> {
mRecord.add(enterEvent(observable, value));
return () -> mRecord.add(exitEvent(observable, value));
});
}
mSubscription = observable.watch((Object value) -> {
mRecord.add(openEvent(value));
return () -> mRecord.add(closeEvent(value));
});
}
public void unsubscribe() {
mSubscription.close();
}
public ReactiveRecorder reset() {
......@@ -48,84 +46,54 @@ public class ReactiveRecorder {
}
/**
* The fluent interface used to perform assertions. Each entered() or exited() call pops the
* The fluent interface used to perform assertions. Each opened() or closed() call pops the
* least-recently-added event from the record, and verifies that it meets the description
* provided by the arguments given to entered() or exited(). Use end() to assert that no more
* provided by the arguments given to opened() or closed(). Use end() to assert that no more
* events were received.
*/
public class Validator {
private Validator() {}
public Validator entered(Observable observable, Object value) {
public Validator opened(Object value) {
Event event = pop();
event.checkType("enter");
event.checkObservable(observable);
event.checkType("open");
event.checkValue(value);
return this;
}
public Validator entered(Object value) {
public Validator closed(Object value) {
Event event = pop();
event.checkType("enter");
event.checkType("close");
event.checkValue(value);
return this;
}
public Validator entered() {
Event event = pop();
event.checkType("enter");
return this;
}
public Validator exited(Observable observable) {
Event event = pop();
event.checkType("exit");
event.checkObservable(observable);
return this;
}
public Validator exited() {
Event event = pop();
event.checkType("exit");
return this;
}
public void end() {
Assert.assertThat(mRecord, emptyIterable());
}
}
private Event pop() {
Assert.assertThat(mRecord, not(emptyIterable()));
return mRecord.remove(0);
}
private String observableName(Observable<?> observable) {
String name = mObservableNames.get(observable);
if (name == null) {
return "(Unknown)";
}
return name;
}
private Event enterEvent(Observable<?> observable, Object value) {
private Event openEvent(Object value) {
Event result = new Event();
result.type = "enter";
result.observable = observable;
result.type = "open";
result.value = value;
return result;
}
private Event exitEvent(Observable<?> observable, Object value) {
private Event closeEvent(Object value) {
Event result = new Event();
result.type = "exit";
result.observable = observable;
result.type = "close";
result.value = value;
return result;
}
private class Event {
public String type;
public Observable<?> observable;
public Object value;
private Event() {}
......@@ -134,12 +102,6 @@ public class ReactiveRecorder {
Assert.assertEquals("Event " + this + " is not an " + type + " event", type, this.type);
}
public void checkObservable(Observable<?> observable) {
Assert.assertEquals("Event " + this + " has wrong observable, expected "
+ observableName(observable),
observable, this.observable);
}
public void checkValue(Object value) {
Assert.assertEquals(
"Event " + this + " has wrong value, expected " + value, value, this.value);
......@@ -147,7 +109,7 @@ public class ReactiveRecorder {
@Override
public String toString() {
return "(" + type + " " + observableName(observable) + ": " + value + ")";
return "(" + type + ": " + value + ")";
}
}
}
......@@ -28,85 +28,56 @@ public class ReactiveRecorderTest {
controller.set(Unit.unit());
controller.reset();
controller.set(Unit.unit());
recorder.verify().entered(Unit.unit()).exited().end();
recorder.verify().opened(Unit.unit()).closed(Unit.unit()).end();
}
@Test(expected = AssertionError.class)
public void testFailEnteredWrongValue() {
public void testFailOpenedWrongValue() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set("actual");
recorder.verify().entered("expected");
recorder.verify().opened("expected");
}
@Test(expected = AssertionError.class)
public void testFailEnteredGotExit() {
public void testFailOpenedGotClosed() {
Controller<String> controller = new Controller<>();
controller.set("before");
ReactiveRecorder recorder = ReactiveRecorder.record(controller).reset();
controller.set("after");
recorder.verify().entered("after");
recorder.verify().opened("after");
}
@Test(expected = AssertionError.class)
public void testFailExitedGotEnter() {
public void testFailClosedGotOpened() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
recorder.verify().exited();
recorder.verify().closed(Unit.unit());
}
@Test(expected = AssertionError.class)
public void testEnteredWrongObservable() {
Controller<Unit> controller = new Controller<>();
Controller<Unit> wrong = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller, wrong);
controller.set(Unit.unit());
recorder.verify().entered(wrong, Unit.unit());
}
@Test(expected = AssertionError.class)
public void testExitedWrongObservable() {
Controller<Unit> controller = new Controller<>();
Controller<Unit> wrong = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller, wrong);
controller.set(Unit.unit());
wrong.set(Unit.unit());
recorder.reset();
controller.reset();
recorder.verify().exited(wrong);
public void testFailGetNotificationsAfterUnsubscribe() {
Controller<String> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
recorder.unsubscribe();
controller.set("unexpected");
recorder.verify().opened("unexpected");
}
@Test
public void testHappyPathForOneObservable() {
public void testHappyPath() {
Controller<Unit> controller = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(controller);
controller.set(Unit.unit());
controller.reset();
controller.set(Unit.unit());
controller.reset();
recorder.verify().entered().exited().entered().exited().end();
}
@Test
public void testHappyPathForManyObservables() {
Controller<String> a = new Controller<>();
Controller<String> b = new Controller<>();
Controller<String> c = new Controller<>();
ReactiveRecorder recorder = ReactiveRecorder.record(a, b, c);
a.set("a");
b.set("b");
c.set("c");
b.reset();
a.reset();
c.reset();
recorder.verify()
.entered(a, "a")
.entered(b, "b")
.entered(c, "c")
.exited(b)
.exited(a)
.exited(c)
.opened(Unit.unit())
.closed(Unit.unit())
.opened(Unit.unit())
.closed(Unit.unit())
.end();
}
}
......@@ -952,14 +952,14 @@ under the hood to know when to notify.
### Testing
One of the most important aspects of using `Observable`s is that they are very
testable. The `Observable` cleanly separates the concerns of *mutating* program
state and *responding to* program state. Reactors, or observers, registered in
`watch()` methods tend to be **functional**, i.e. with no side effects, though
this isn't a strict requirement (see the above section).
testable. Although `Observer`s themselves are not pure-functional (i.e. they
tend to mutate program state), this is done in such a way that the mutations in
the form of state transitions in `Observable`s are easy to track, and therefore
easy to test.
If you write a class that implements `Observable` or returns an `Observable` in
one of its methods, it's easy to test the events it emits by using the
`ReactiveRecorder` test utility function. This class, which is only allowed in
`ReactiveRecorder` test utility module. This class, which is only allowed in
tests, provides a fluent interface for describing the expected output of an
`Observable`.
......@@ -988,7 +988,7 @@ public class FlipFlopTest {
ReactiveRecorder recorder = ReactiveRecorder.record(f);
f.flip();
// A single activation should have been emitted.
recorder.verify().entered().end();
recorder.verify().opened(Unit.unit()).end();
}
@Test
......@@ -998,17 +998,40 @@ public class FlipFlopTest {
f.flip();
f.flip();
// Expect an activation followed by a deactivation.
recorder.verify().entered().exited().end();
recorder.verify().opened(Unit.unit()).closed(Unit.unit()).end();
}
}
```
`ReactiveRecorder`'s `entered()` and `exited()` methods can also take arguments
to perform assertions on the activation data. `ReactiveRecorder.record()` can
also take arbitrarily many `Observable` arguments and receive the events of all
of the given `Observable`s. In this case, the `entered()` and `exited()` methods
have overloads that take an `Observable` as an argument, which can be used to
assert *which* `Observable` emitted an event.
The `ReactiveRecorder` class works by calling `watch()` on the given
`Observable` and storing the activations and deactivations it observes in a
list. The `verify()` method opens a domain-specific language for performing
assertions on the activation data, using `opened()` and `closed()` to check
which data has been activated and deactivated. The transitions recorded must
occur in the same order as the `opened()` and `closed()` calls to pass the test.
The `end()` method asserts that no more transitions occurred.
You can test behaviors that should occur when closing a `watch()` scope by
calling `recorder.unsubscribe()`. For example, every `Observable` implementation
should close all existing `Scopes` emitted from an `Observer` when that
`Observer`'s `watch()` scope is closed:
```java
@Test
public void testUnsubscribeCloses() {
FlipFlop f = new FlipFlop();
ReactiveRecorder recorder = ReactiveRecorder.record(f);
f.flip();
// Clear the record; we don't care about the activation from flip().
recorder.reset();
recorder.unsubscribe();
// Unsubscribing should implicitly close the scope.
recorder.verify().closed(Unit.unit()).end();
}
```
Once a `ReactiveRecorder` unsubscribes, it will not get any new events from the
`Observable` it was recording.
## When to use Observables
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment