Logo is a programming environment created in the 1960s. It was, for many decades, a popular way to teach children how to code—I have fond memories of writing Logo programs back in high school. At its core, it is a method for building graphics via imperative instructions.
In this part of the book, we’ll build an application called Spec Logo. The starting point is an already-functioning interpreter and a barebones UI. In the following chapters, we’ll bolt on additional features to this codebase.
This chapter provides a second opportunity to test-drive Redux. It covers the following topics:
By the end of the chapter, you’ll have learned how to test-drive complex Redux reducers and middleware.
The code files for this chapter can be found here:
The interface has two panes: the left pane is the drawing pane, which is where the output from the Logo script appears. On the right side is a prompt where the user can edit instructions:
Figure 14.1: The Spec Logo interface
Look at the screenshot. You can see the following:
Although we won’t be writing any Logo code in this chapter, it’s worth spending some time playing around and making your own drawings with the interpreter. Here’s a list of instructions that you can use:
It’s also worth looking through the codebase. The src/parser.js file and the src/language directory contain the Logo interpreter. There are also corresponding test files in the test directory. We won’t be modifying these files, but you may be interested in seeing how this functionality has been tested.
There is a single Redux reducer in src/reducers/script.js. Its defaultState definition neatly encapsulates everything needed to represent the execution of a Logo program. Almost all the app’s React components use this state in some way.
In this chapter, we’ll be adding two more reducers into this directory: one for undo/redo and one for prompt focus. We’ll be making modifications to three React components: MenuButtons, Prompt, and ScriptName.
Let’s start by building a new reducer, named withUndoRedo.
In this section, we’ll add undo and redo buttons at the top of the page, which allow the user to undo and redo statements that they’ve previously run. They’ll work like this:
Aside from adding button elements, the work involved here is building a new reducer, withUndoRedo, which will decorate the script reducer. This reducer will return the same state as the script reducer, but with two additional properties: canUndo and canRedo. In addition, the reducer stores past and future arrays within it that record the past and future states. These will never be returned to the user, just stored, and will replace the current state should the user choose to undo or redo.
The reducer will be a higher-order function that, when called with an existing reducer, returns a new reducer that returns the state we’re expecting. In our production code, we’ll replace this store code:
combineReducers({ script: scriptReducer })
We’ll replace it with this decorated reducer, which takes exactly the same reducer and wraps it in the withUndoRedo reducer that we’ll build in this section:
combineReducers({ script: withUndoRedo(scriptReducer) })
To test this, we’ll need to use a spy to act in place of the script reducer, which we’ll call decoratedReducerSpy.
Let’s make a start by building the reducer itself, before adding buttons to exercise the new functionality:
import {
withUndoRedo
} from "../../src/reducers/withUndoRedo";
describe("withUndoRedo", () => {
let decoratedReducerSpy;
let reducer;
beforeEach(() => {
decoratedReducerSpy = jest.fn();
reducer = withUndoRedo(decoratedReducerSpy);
});
describe("when initializing state", () => {
it("calls the decorated reducer with undefined state and an action", () => {
const action = { type: "UNKNOWN" };
reducer(undefined, action);
expect(decoratedReducerSpy).toBeCalledWith(
undefined,
action);
});
});
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
reducer(state, action);
};
};
it("returns a value of what the inner reducer returns", () => {
decoratedReducerSpy.mockReturnValue({ a: 123 });
expect(reducer(undefined)).toMatchObject(
{ a : 123 }
);
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
return reducer(state, action);
};
}
it("cannot undo", () => {
expect(reducer(undefined)).toMatchObject({
canUndo: false
});
});
it("cannot redo", () => {
expect(reducer(undefined)).toMatchObject({
canRedo: false
});
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
return {
canUndo: false,
canRedo: false,
...reducer(state, action)
};
};
}
describe("performing an action", () => {
const innerAction = { type: "INNER" };
const present = { a: 123 };
const future = { b: 234 };
beforeEach(() => {
decoratedReducerSpy.mockReturnValue(future);
});
it("can undo after a new present has been provided", () => {
const result = reducer(
{ canUndo: false, present },
innerAction
);
expect(result.canUndo).toBeTruthy();
});
});
export const withUndoRedo = (reducer) => {
return (state, action) => {
if (state === undefined)
return {
canUndo: false,
canRedo: false,
...reducer(state, action)
};
return {
canUndo: true
};
};
};
it("forwards action to the inner reducer", () => {
reducer(present, innerAction);
expect(decoratedReducerSpy).toBeCalledWith(
present,
innerAction
);
});
if (state === undefined)
...
reducer(state, action);
return {
canUndo: true
};
it("returns the result of the inner reducer", () => {
const result = reducer(present, innerAction);
expect(result).toMatchObject(future);
});
const newPresent = reducer(state, action);
return {
...newPresent,
canUndo: true
};
const present = { a: 123, nextInstructionId: 0 };
const future = { b: 234, nextInstructionId: 1 };
...
it("returns the previous state if nextInstructionId does not increment", () => {
decoratedReducerSpy.mockReturnValue({
nextInstructionId: 0
});
const result = reducer(present, innerAction);
expect(result).toBe(present);
});
const newPresent = reducer(state, action);
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
return {
...newPresent,
canUndo: true
};
}
return state;
This covers all the functionality for performing any actions other than Undo and Redo. The next section covers Undo.
We’ll create a new Redux action, of type UNDO, which causes us to push the current state into a new array called past:
describe("withUndoRedo", () => {
const undoAction = { type: "UNDO" };
const innerAction = { type: "INNER" };
const present = { a: 123, nextInstructionId: 0 };
const future = { b: 234, nextInstructionId: 1 };
...
});
describe("undo", () => {
let newState;
beforeEach(() => {
decoratedReducerSpy.mockReturnValue(future);
newState = reducer(present, innerAction);
});
it("sets present to the latest past entry", () => {
const updated = reducer(newState, undoAction);
expect(updated).toMatchObject(present);
});
});
Performing an action within a beforeEach block
Notice the call to the reducer function in the beforeEach setup. This function is the function under test, so it could be considered part of the Act phase that we usually keep within the test itself. However, in this case, the first call to reducer is part of the test setup, since all these tests rely on having performed at least one action that can then be undone. In this way, we can consider this reducer call to be part of the Assert phase.
export const withUndoRedo = (reducer) => {
let past;
return (state, action) => {
if (state === undefined)
...
switch(action.type) {
case "UNDO":
return past;
default:
const newPresent = reducer(state, action);
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
past = state;
return {
...newPresent,
canUndo: true
};
}
return state;
}
};
};
it("can undo multiple levels", () => {
const futureFuture = {
c: 345, nextInstructionId: 3
};
decoratedReducerSpy.mockReturnValue(futureFuture);
newState = reducer(newState, innerAction);
const updated = reducer(
reducer(newState, undoAction),
undoAction
);
expect(updated).toMatchObject(present);
});
export const withUndoRedo = (reducer) => {
let past = [];
return (state, action) => {
if (state === undefined)
...
switch(action.type) {
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
return lastEntry;
default:
const newPresent = reducer(state, action);
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
past = [ ...past, state ];
return {
...newPresent,
canUndo: true
};
}
return state;
}
};
};
it("sets canRedo to true after undoing", () => {
const updated = reducer(newState, undoAction);
expect(updated.canRedo).toBeTruthy();
});
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
return {
canRedo: true
};
That’s all there is to the UNDO action. Next, let’s add the REDO action.
Redo is very similar to undo, just reversed:
describe("withUndoRedo", () => {
const undoAction = { type: "UNDO" };
const redoAction = { type: "REDO" };
...
});
describe("redo", () => {
let newState;
beforeEach(() => {
decoratedReducerSpy.mockReturnValueOnce(future);
newState = reducer(present, innerAction);
newState = reducer(newState, undoAction);
});
it("sets the present to the latest future entry", () => {
const updated = reducer(newState, redoAction);
expect(updated).toMatchObject(future);
});
});
let past = [], future;
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
future = state;
case "UNDO":
...
case "REDO":
return future;
default:
...
const future = { b: 234, nextInstructionId: 1 };
const futureFuture = { c: 345, nextInstructionId: 3 };
beforeEach(() => {
decoratedReducerSpy.mockReturnValueOnce(future);
decoratedReducerSpy.mockReturnValueOnce(
futureFuture
);
newState = reducer(present, innerAction);
newState = reducer(newState, innerAction);
newState = reducer(newState, undoAction);
newState = reducer(newState, undoAction);
});
it("can redo multiple levels", () => {
const updated = reducer(
reducer(newState, redoAction),
redoAction
);
expect(updated).toMatchObject(futureFuture);
});
let past = [], future = [];
case "UNDO":
const lastEntry = past[past.length - 1];
past = past.slice(0, -1);
future = [ ...future, state ];
case "REDO":
const nextEntry = future[future.length - 1];
future = future.slice(0, -1);
return nextEntry;
it("returns to previous state when followed by an undo", () => {
const updated = reducer(
reducer(newState, redoAction),
undoAction
);
expect(updated).toMatchObject(present);
});
case "REDO":
const nextEntry = future[future.length - 1];
past = [ ...past, state ];
future = future.slice(0, -1);
return nextEntry;
To test for this scenario, you can simulate the sequence and check that you expect to return undefined. This test isn’t great in that we really shouldn’t be sending a REDO action when canRedo returns false, but that’s what our test ends up doing:
it("return undefined when attempting a do, undo, do, redo sequence", () => {
decoratedReducerSpy.mockReturnValue(future);
let newState = reducer(present, innerAction);
newState = reducer(newState, undoAction);
newState = reducer(newState, innerAction);
newState = reducer(newState, redoAction);
expect(newState).not.toBeDefined();
});
if (
newPresent.nextInstructionId !=
state.nextInstructionId
) {
past = [ ...past, state ];
future = [];
return {
...newPresent,
canUndo: true
};
}
import {
withUndoRedo
} from "./reducers/withUndoRedo";
export const configureStore = (
storeEnhancers = [],
initialState = {}
) => {
return createStore(
combineReducers({
script: withUndoRedo(scriptReducer)
}),
initialState,
compose(...storeEnhancers)
);
};
Your tests should all be passing and the app should still run.
However, the undo and redo functionality is still not accessible. For that, we need to add some buttons to the menu bar.
The final piece to this puzzle is adding buttons to trigger the new behavior by adding Undo and Redo buttons to the menu bar:
describe("undo button", () => {
it("renders", () => {
renderWithStore(<MenuButtons />);
expect(buttonWithLabel("Undo")).not.toBeNull();
});
});
export const MenuButtons = () => {
...
return (
<>
<button>Undo</button>
<button
onClick={() => dispatch(reset())}
disabled={!canReset}
>
Reset
</button>
</>
);
};
it("is disabled if there is no history", () => {
renderWithStore(<MenuButtons />);
expect(
buttonWithLabel("Undo").hasAttribute("disabled")
).toBeTruthy();
});
<button disabled={true}>Undo</button>
it("is enabled if an action occurs", () => {
renderWithStore(<MenuButtons />);
dispatchToStore({
type: "SUBMIT_EDIT_LINE",
text: "forward 10 "
});
expect(
buttonWithLabel("Undo").hasAttribute("disabled")
).toBeFalsy();
});
export const MenuButtons = () => {
const {
canUndo, nextInstructionId
} = useSelector(({ script }) => script);
...
const canReset = nextInstructionId !== 0;
return (
<>
<button disabled={!canUndo}>Undo</button>
<button
onClick={() => dispatch(reset())}
disabled={!canReset}
>
Reset
</button>
</>
);
}
);
it("dispatches an action of UNDO when clicked", () => {
renderWithStore(<MenuButtons />);
dispatchToStore({
type: "SUBMIT_EDIT_LINE",
text: "forward 10 "
});
click(buttonWithLabel("Undo"));
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "UNDO" });
});
const reset = () => ({ type: "RESET" });
const undo = () => ({ type: "UNDO" });
export const MenuButtons = () => {
...
return (
<>
<button
onClick={() => dispatch(undo())}
disabled={!canUndo}
>
Undo
</button>
...
</>
);
};
That’s the last change needed. The undo and redo functionality is now complete.
Next up, we’ll move from building a Redux reducer to building Redux middleware.
In this section, we’ll update our app to save the current state to local storage, a persistent data store managed by the user’s web browser. We’ll do that by way of Redux middleware.
Each time a statement is executed in the Spec Logo environment, the entire set of parsed tokens will be saved via the browser’s LocalStorage API. When the user next opens the app, the tokens will be read and replayed through the parser.
The parseTokens function
As a reminder, the parser (in src/parser.js) has a parseTokens function. This is the function we’ll call from within our middleware, and in this section, we’ll build tests to assert that we’ve called this function.
We’ll write a new piece of Redux middleware for the task. The middleware will pull out two pieces of the script state: name and parsedTokens.
Before we begin, let’s review the browser LocalStorage API:
Let’s test-drive our middleware:
import {
save
} from "../../src/middleware/localStorage";
describe("localStorage", () => {
const data = { a: 123 };
let getItemSpy = jest.fn();
let setItemSpy = jest.fn();
beforeEach(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: getItemSpy,
setItem: setItemSpy
}});
});
});
describe("save middleware", () => {
const name = "script name";
const parsedTokens = ["forward 10"];
const state = { script: { name, parsedTokens } };
const action = { type: "ANYTHING" };
const store = { getState: () => state };
let next;
beforeEach(() => {
next = jest.fn();
});
const callMiddleware = () =>
save(store)(next)(action);
it("calls next with the action", () => {
callMiddleware();
expect(next).toBeCalledWith(action);
});
});
export const save = store => next => action => {
next(action);
};
it("returns the result of next action", () => {
next.mockReturnValue({ a : 123 });
expect(callMiddleware()).toEqual({ a: 123 });
});
export const save = store => next => action => {
return next(action);
};
it("saves the current state of the store in localStorage", () => {
callMiddleware();
expect(setItemSpy).toBeCalledWith("name", name);
expect(setItemSpy).toBeCalledWith(
"parsedTokens",
JSON.stringify(parsedTokens)
);
});
export const save = store => next => action => {
const result = next(action);
const {
script: { name, parsedTokens }
} = store.getState();
localStorage.setItem("name", name);
localStorage.setItem(
"parsedTokens",
JSON.stringify(parsedTokens)
);
return result;
};
import {
load, save
} from "../../src/middleware/localStorage";
...
describe("load", () => {
describe("with saved data", () => {
beforeEach(() => {
getItemSpy.mockReturnValueOnce("script name");
getItemSpy.mockReturnValueOnce(
JSON.stringify([ { a: 123 } ])
);
});
it("retrieves state from localStorage", () => {
load();
expect(getItemSpy).toBeCalledWith("name");
expect(getItemSpy).toHaveBeenLastCalledWith(
"parsedTokens"
);
});
});
});
export const load = () => {
localStorage.getItem("name");
localStorage.getItem("parsedTokens");
};
describe("load", () => {
let parserSpy;
describe("with saved data", () => {
beforeEach(() => {
parserSpy = jest.fn();
parser.parseTokens = parserSpy;
...
});
it("calls to parsedTokens to retrieve data", () => {
load();
expect(parserSpy).toBeCalledWith(
[ { a: 123 } ],
parser.emptyState
);
});
});
});
import * as parser from "../parser";
export const load = () => {
localStorage.getItem("name");
const parsedTokens = JSON.parse(
localStorage.getItem("parsedTokens")
);
parser.parseTokens(parsedTokens, parser.emptyState);
};
it("returns re-parsed draw commands", () => {
parserSpy.mockReturnValue({ drawCommands: [] });
expect(
load().script
).toHaveProperty("drawCommands", []);
});
export const load = () => {
localStorage.getItem("name");
const parsedTokens = JSON.parse(
localStorage.getItem("parsedTokens")
);
return {
script: parser.parseTokens(
parsedTokens, parser.emptyState
)
};
};
it("returns name", () => {
expect(load().script).toHaveProperty(
"name",
"script name"
);
});
export const load = () => {
const name = localStorage.getItem("name");
const parsedTokens = JSON.parse(
localStorage.getItem("parsedTokens")
);
return {
script: {
...parser.parseTokens(
parsedTokens, parser.initialState
),
name
}
};
};
it("returns undefined if there is no state saved", () => {
getItemSpy.mockReturnValue(null);
expect(load()).not.toBeDefined();
});
if (parsedTokens && parsedTokens !== null) {
return {
...
};
}
...
import {
save, load
} from "./middleware/localStorage";
export const configureStore = (
storeEnhancers = [],
initialState = {}
) => {
return createStore(
combineReducers({
script: withUndoRedo(scriptReducer)
}),
initialState,
compose(
...[
applyMiddleware(save),
...storeEnhancers
]
)
);
};
export const configureStoreWithLocalStorage = () =>
configureStore(undefined, load());
import {
configureStoreWithLocalStorage
} from "./store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={configureStoreWithLocalStorage()}>
<App />
</Provider>
);
That’s it. If you like, this is a great time to run the app for a manual test and try it. Open the browser window, type a few commands, and try it out!
If you’re stuck for commands to run a manual test, you can use these:
forward 100 right 90 to drawSquare repeat 4 [ forward 100 right 90 ] end drawSquare
These commands exercise most of the functionality within the interpreter and display. They’ll come in handy in Chapter 15, Adding Animation, when you’ll want to be manually testing as you make changes.
You’ve learned how to test-drive Redux middleware. For the final part of the chapter, we will write another reducer, this time one that helps us manipulate the browser’s keyboard focus.
The user of our application will, most of the time, be typing in the prompt at the bottom right of the screen. To help them out, we’ll move the keyboard focus to the prompt when the app is launched. We should also do this when another element—such as the name text field or the menu buttons—has been used but has finished its job. Then, the focus should revert back to the prompt, ready for another instruction.
React doesn’t support setting focus, so we need to use a React ref on our components and then drop it into the DOM API.
We’ll do this via a Redux reducer. It will have two actions: PROMPT_FOCUS_REQUEST and PROMPT_HAS_FOCUSED. Any of the React components in our application will be able to dispatch the first action. The Prompt component will listen for it and then dispatch the second, once it has focused.
We’ll start, as ever, with the reducer:
import {
environmentReducer as reducer
} from "../../src/reducers/environment";
describe("environmentReducer", () => {
it("returns default state when existing state is undefined", () => {
expect(reducer(undefined, {})).toEqual({
promptFocusRequest: false
});
});
});
const defaultState = {
promptFocusRequest: false
};
export const environmentReducer = (
state = defaultState,
action) => {
return state;
};
it("sets promptFocusRequest to true when receiving a PROMPT_FOCUS_REQUEST action", () => {
expect(
reducer(
{ promptFocusRequest: false},
{ type: "PROMPT_FOCUS_REQUEST" }
)
).toEqual({
promptFocusRequest: true
});
});
export const environmentReducer = (
state = defaultState,
action
) => {
switch (action.type) {
case "PROMPT_FOCUS_REQUEST":
return { promptFocusRequest: true };
}
return state;
};
it("sets promptFocusRequest to false when receiving a PROMPT_HAS_FOCUSED action", () => {
expect(
reducer(
{ promptFocusRequest: true},
{ type: "PROMPT_HAS_FOCUSED" }
)
).toEqual({
promptFocusRequest: false
});
});
export const environmentReducer = (...) => {
switch (action.type) {
...,
case "PROMPT_HAS_FOCUSED":
return { promptFocusRequest: false };
}
...
}
...
import {
environmentReducer
} from "./reducers/environment";
export const configureStore = (
storeEnhancers = [],
initialState = {}
) => {
return createStore(
combineReducers({
script: withUndoRedo(logoReducer),
environment: environmentReducer
}),
...
);
};
That gives us a new reducer that’s hooked into the Redux store. Now, let’s make use of that.
Let’s move on to the most difficult part of this: focusing the actual prompt. For this, we’ll need to introduce a React ref:
describe("prompt focus", () => {
it("sets focus when component first renders", () => {
renderInTableWithStore(<Prompt />);
expect(
document.activeElement
).toEqual(textArea());
});
});
import
React, { useEffect, useRef, useState }
from "react";
export const Prompt = () => {
...
const inputRef = useRef();
useEffect(() => {
inputRef.current.focus();
}, [inputRef]);
return (
...
<textarea
ref={inputRef}
/>
...
);
};
import {
...,
dispatchToStore,
} from "./reactTestExtensions";
const jsdomClearFocus = () => {
const node = document.createElement("input");
document.body.appendChild(node);
node.focus();
node.remove();
}
it("calls focus on the underlying DOM element if promptFocusRequest is true", async () => {
renderInTableWithStore(<Prompt />);
jsdomClearFocus();
dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" });
expect(document.activeElement).toEqual(textArea());
});
export const Prompt = () => {
const nextInstructionId = ...
const promptFocusRequest = useSelector(
({ environment: { promptFocusRequest } }) =>
promptFocusRequest
);
...
};
useEffect(() => {
inputRef.current.focus();
}, [promptFocusRequest]);
it("dispatches an action notifying that the prompt has focused", () => {
renderWithStore(<Prompt />);
dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" });
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "PROMPT_HAS_FOCUSED" });
});
const submitEditLine = ...
const promptHasFocused = () => (
{ type: "PROMPT_HAS_FOCUSED" }
);
inputRef.current.focus();
dispatch(promptHasFocused());
}, [promptFocusRequest]);
There is a slight issue with this last code snippet. The dispatched PROMPT_HAS_FOCUSED action will set promptFocusRequest back to false. That then causes the useEffect hook to run a second time, with the component re-rendering. This is clearly not intended, nor is it necessary. However, since it has no discernable effect on the user, we can skip fixing it at this time.
This completes the Prompt component, which now steals focus anytime the promptFocusRequest variable changes value.
All that’s left is to call the request action when required. We’ll do this for ScriptName, but you could also do it for the buttons in the menu bar:
it("dispatches a prompt focus request", () => {
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "PROMPT_FOCUS_REQUEST" });
});
const submitScriptName = ...
const promptFocusRequest = () => ({
type: "PROMPT_FOCUS_REQUEST",
});
const completeEditingScriptName = () => {
if (editingScriptName) {
toggleEditingScriptName();
dispatch(submitScriptName(updatedScriptName));
dispatch(promptFocusRequest());
}
};
That’s it! If you build and run now, you’ll see how focus is automatically given to the prompt textbox, and if you edit the script name (by clicking on it, typing something, and then hitting Enter), you’ll see that focus returns to the prompt.
You should now have a good understanding of test-driving complex Redux reducers and middleware.
First, we added support undo/redo with a Redux decorator reducer. Then, we built Redux middleware to save and load existing states via the browser’s LocalStorage API. And finally, we looked at how to test-drive changing the browser’s focus.
In the next chapter, we’ll look at how to test-drive something much more intricate: animation.
Wikipedia entry on the Logo programming language: