diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..7b16f790 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v14.19.0 diff --git a/README.md b/README.md index 33c4dad8..913f4a3e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This is shorthand for the command `open coverage/lcov-report/index.html` and wil ### Adagrams Demo Game -In addition to the provided unit tests, we provided a demo game application that uses Adagrams code that you will implement. You can play the game as you implement each wave of the project and verify that game functionality begins to work, in addition to passing unit tests. Don't forget; making the demo game work is optional-- **passing the unit tests is required.** +In addition to the provided unit tests, we provided a demo game application that uses Adagrams code that you will implement. You can play the game as you implement each wave of the project and verify that game functionality begins to work, in addition to passing unit tests. Don't forget; making the demo game work is optional—**passing the unit tests is required.**
@@ -74,11 +74,11 @@ You can start the demo game application with the following command: $ yarn run demo-game ``` -This will start the Adagrams prompt, and you can start a new game by typing `start` (or `start ` for a game with multiple players). +This will start the Adagrams menu. You can start a new game, learn how to play, or quit. -Once the game has started each player is prompted to play anagrams from the displayed letter bank until their turn completes. At the end of each round the player who played the best word (according to the logic you will implement in wave 4) is awarded points based on that word. Once all rounds are completed the game announced who won with the point total for that player. +Once the game has started, each player is prompted to play anagrams from the displayed letter bank until their turn completes. At the end of each round the player who played the best word (according to the logic you will implement in wave 4) is awarded points based on that word. Once all rounds are completed the game announced who won with the point total for that player. -The game is fairly rudimentary and has a few bugs remaining, such as needing to type 'exit' to complete your turn. If you've completed all of the waves for this project and wish to continue working on terminal JavaScript code, feel free to ask your instructors for suggestions on bug fixes or improvements to make for the game code. +The game is fairly rudimentary and has a few bugs remaining. If you've completed all of the waves for this project and wish to continue working on terminal JavaScript code, feel free to ask your instructors for suggestions on bug fixes or improvements to make for the game code. [The game's code is documented](./src/demo-react#react-demo-game) in README files in its various folders. #### Conclusion diff --git a/babel.config.js b/babel.config.js index 1bc60240..921d102c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -12,6 +12,7 @@ const presets = [ corejs: "3.8.0", }, ], + "@babel/preset-react" ]; const plugins = [ diff --git a/package.json b/package.json index 35b7d9f6..5549f974 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "jest", "coverage": "open coverage/lcov-report/index.html", - "demo-game": "babel-node src/demo.js" + "demo-game": "babel-node src/demo-react/cli.js" }, "repository": { "type": "git", @@ -24,14 +24,19 @@ "@babel/node": "^7.2.2", "@babel/plugin-proposal-class-properties": "^7.4.4", "@babel/preset-env": "^7.4.4", + "@babel/preset-react": "^7.17.12", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^24.8.0", "babel-plugin-module-resolver": "^3.2.0", + "ink-testing-library": "^2.1.0", "jest": "^24.8.0", "regenerator-runtime": "^0.12.1" }, "dependencies": { "core-js": "^3.8.0", - "vorpal": "^1.12.0" + "ink": "^3.2.0", + "ink-text-input": "^4.0.3", + "prop-types": "^15.8.1", + "react": "17.0.2" } } diff --git a/src/demo-react/README.md b/src/demo-react/README.md new file mode 100644 index 00000000..319e94ea --- /dev/null +++ b/src/demo-react/README.md @@ -0,0 +1,28 @@ +# React Demo Game + +The demo game is present to help you test your [Adagrams implementation](../adagrams.js). At its core, the demo game is a bunch of UI that is built around the Adagrams API, which consists of the four methods `drawLetters`, `usesAvailableLetters`, `scoreWord`, and `highestScoreFrom`. + +As a result, when you first start the demo game, before you've implemented any of the Waves, the demo game won't function correctly! Specifically, it starts off thinking that every hand of letters is `["H", "E", "L", "L", "O", "W", "O", "R", "L", "D"]`, that any word at all "uses" those letters, and that everything is worth 0 points. As you implement the Adagrams functions (and pass its tests), you make it so the demo game functions correctly. + +## Adagrams Proxy—The Proxy Pattern + +The way the demo game functions *without* your implementation is by applying the [Proxy Pattern](https://en.wikipedia.org/wiki/Proxy_pattern) to the methods of your [adagrams.js](../adagrams.js). The Proxy in this case is an object defined in [adagrams-proxy.js](./adagrams-proxy.js). This proxy object implements the same "interface" as your real Adagrams—that is, it defines the same four functions, with the same names, parameters, and return types—and provides default behavior for any cases where the real Adagrams returns `undefined`—that's the default return value for any JavaScript function. When you start implementing your Adagrams, and the functions stop returning `undefined`, the Proxy automatically switches to using your implementation for the function instead of its default behavior. + +The traditional definition of the Proxy Pattern explains that two "concrete" classes will inherit from an "interface". In other languages besides JavaScript, an interface is a way to explicitly specify the names, parameters, and return values of a class's methods without providing any implementation for them. An interface is usually described as a "contract" that code in a function expects an instance object of a class that implements the interface to fulfill. JavaScript doesn't have a way to explicitly define an interface in code; you call a method, and deal with whatever the result is. (A return value of `undefined` or a runtime error might be that result!) So when we implement the Proxy Pattern in JavaScript, our Proxy object fulfills the "implicit" interface for our real object. In this case, we know what the interface is because there are tests, other functions outside the module, and documents describing it. The Proxy and Real Adagrams objects implement the same "implicit" interface because they satisfy the expectations of the code that uses them. If the idea of an implicit interface makes you feel uncomfortable, then you might like TypeScript. + +## How is the game structured? +### Ink +The demo game is built with a framework called [Ink](https://github.com/vadimdemedes/ink#readme), which makes it so you can develop terminal applications using [React](https://reactjs.org/). This means you will find React concepts—props, state, jsx, etc.—used throughout the demo game code. Ink provides the services that reconcile React's render tree with the standard output of the terminal shell (for you, probably `zsh` or `bash`). If you're familiar with React, you know it's most commonly used to generate HTML on web pages. As you might guess, HTML in a browser can produce much richer visual output than the terminal can. You can tell this is true by looking at the render functions of React components that use Ink primitives. There are only a few of those primitives—`Box` and `Spacer` for layout, `Text`, `Transform`, and `Newline` for writing text with colors and styles, and `Static` to make the output stay instead of being refreshed. HTML has vastly more tags to make elements from. Nonetheless, the six tags Ink gives you to work with make it possible to create surprisingly dynamic and visual apps on the terminal, even to the point where you can use flexbox to arrange text in your Ink apps! + +This demo game demonstrates how you can combine these pieces to create an interactive terminal app, but incidentally so does running the tests. The tests in this project are run by a JavaScript program called Jest, and Jest also uses Ink for its text rendering. Run the tests in watch mode (`yarn test --watch`) and play with the Watch Usage options to see a different Ink app in action. + +### App structure and folder Layout +The app itself starts at [cli.js](./cli.js), which is where we call Ink's `render` method on the App component. The [App](./app.js) component looks simple—it is a `ScreenDisplayer` inside a `GameStateStore`—but that simple definition hides all of the complexity of the whole app. Putting the `ScreenDisplayer` in the `GameStateStore` is the connection point between all of the state management code inside `gamestate` and all of the components inside of `screens`. (The rest of the custom components, which the screen components depend on, are in the `components` folder.) In a way, then, the `gamestate` is "global"—everything in the app has access to the game state via React Context. You can read the consequence of that decision in code: most of the `screen` components expect game state to be available to them via `useGameStateContext`, and even some of the reusable components (e.g. [timer](./components/timer.js)) expect it, too. + +More details about screens, gamestate, and the reusable components can be found by going to their folders: +- [screens/](./screens/): React components that represent the various "screens" that players move through during the game. The [ScreenDisplayer](./screens/index.js) chooses the screen based on the current state. +- [gamestate/](./gamestate/): Reducers and actions that store and allow changes on the state of the game and its UI. This folder follows a pattern that is like [redux](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#what-is-redux) but implemented with React's own [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer). +- [components/](./components/): React components that can be reused. This includes simple display-only components like [Button](./components/button.js), complex input-handling components like [NumberField](./components/number-field.js), and more-esoteric components such as the context-providing [GameStateStore](./components/gamestate-context.js). + +## History +This is not the first incarnation of the JS Adagrams demo game. An [Architectural Decision Record](./docs/adr.md) describes the latest iteration as well as reasoning behind its development. \ No newline at end of file diff --git a/src/demo-react/adagrams-proxy.js b/src/demo-react/adagrams-proxy.js new file mode 100644 index 00000000..f25b3d77 --- /dev/null +++ b/src/demo-react/adagrams-proxy.js @@ -0,0 +1,60 @@ +import { + drawLetters, + usesAvailableLetters, + scoreWord, + highestScoreFrom, +} from "adagrams"; + +const Real = { + drawLetters, + usesAvailableLetters, + scoreWord, + highestScoreFrom, +}; + +const Proxy = { + drawLetters() { + const real = Real.drawLetters(); + if (typeof real === 'undefined') { + return ["H", "E", "L", "L", "O", "W", "O", "R", "L", "D"]; + } + + return real; + }, + + usesAvailableLetters(input, lettersInHand) { + const real = Real.usesAvailableLetters(input, lettersInHand); + if (typeof real === 'undefined') { + return true; + } + + return real; + }, + + scoreWord(word) { + const real = Real.scoreWord(word); + if (typeof real === 'undefined') { + return 1; + } + + return real; + }, + + highestScoreFrom(words) { + const real = Real.highestScoreFrom(words); + if (typeof real === 'undefined') { + if (words.length < 1) { + return {}; + } + + return { + word: words[0], + score: Proxy.scoreWord(words[0]), + }; + } + + return real; + }, +}; + +export default Proxy; diff --git a/src/demo-react/app.js b/src/demo-react/app.js new file mode 100644 index 00000000..40d42f2c --- /dev/null +++ b/src/demo-react/app.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import ScreenDisplayer from './screens'; +import { GameStateStore } from './components/gamestate-context'; + +export default function App() { + return ( + + + + ) +} diff --git a/src/demo-react/cli.js b/src/demo-react/cli.js new file mode 100644 index 00000000..edc4c846 --- /dev/null +++ b/src/demo-react/cli.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import React from 'react'; +import { render } from 'ink'; + +import App from './app'; + +render(); diff --git a/src/demo-react/components/README.md b/src/demo-react/components/README.md new file mode 100644 index 00000000..7a82e3d8 --- /dev/null +++ b/src/demo-react/components/README.md @@ -0,0 +1,9 @@ +# components +Ink-based React components that can be reused by [screens](../screens/). + +## Timer +The timer warrants a deeper discussion. Its `useEffect` callback actually plays an integral role for the [in-game screen](../screens/game.js). Not only does the timer *display* the remaining time in the round that is stored in gamestate, but it also sends `TICK` actions to the state. The `TICK` actions are hooked up to a one-second timer by registering a callback with the `setInterval` JavaScript API. That API also provides a mechanism for deregistering the callback (`clearInterval`), so that the ticking can stop when the timer is removed from the screen. + +Handling callback registration and deregistration is what `useEffect` is really for in React (see: [Synchronizing with Effects](https://beta.reactjs.org/learn/synchronizing-with-effects)), even though you will probably most commonly encounter `useEffect` being used for fetching data to update a component's state. React 18 strict mode will introduce surprising behavior for developers who are used to that pattern, and the docs have been updated explaining [how to use useEffect for fetching](https://beta.reactjs.org/learn/synchronizing-with-effects#fetching-data) without running into the problems that are inherent to following this pattern. + +There's an interesting consequence of registering the timer here in the React component: nothing in `gamestate` knows that ticks are 1 second. This component can choose to tick faster or slower, by simply changing the constant passed into `setInterval`, and the game logic will respond the same irrespective of the "wall clock time" that has actually passed. That means unit tests for the gamestate do not depend on time, won't run faster or slower if you decrease or increase the time between ticks, and don't require any timer mocks to work around slow tests. It also means that the game's ticking behavior can be controlled entirely from `Timer`. Want to implement the features mentioned in TODO in the [reducer](src/demo-react/gamestate/reducer.js#L52-L53)? Pausing the timer between turns means updating the interval registration here in `Timer`. In fact, pausing the timer might not even be a feature of `reducer` at all, depending on the chosen implementation. As long as a `TICK` isn't dispatched, the `gamestate` will not change. diff --git a/src/demo-react/components/button.js b/src/demo-react/components/button.js new file mode 100644 index 00000000..bc2ab8a1 --- /dev/null +++ b/src/demo-react/components/button.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Box, Text } from 'ink'; + +export default function Button({ children, color, isSelected }) { + return ( + + { children } + + ); +} + +Button.propTypes = { + children: PropTypes.node, + color: PropTypes.string, + isSelected: PropTypes.bool.isRequired +}; + +Button.defaultProps = { + isSelected: false +} diff --git a/src/demo-react/components/error-viewer.js b/src/demo-react/components/error-viewer.js new file mode 100644 index 00000000..a92cd8dc --- /dev/null +++ b/src/demo-react/components/error-viewer.js @@ -0,0 +1,10 @@ +import React from 'react'; + +import { Text } from 'ink'; + +import { useGameStateContext } from './gamestate-context'; + +export default function ErrorViewer() { + const { state } = useGameStateContext(); + return { state.lastError } +} diff --git a/src/demo-react/components/gamestate-context.js b/src/demo-react/components/gamestate-context.js new file mode 100644 index 00000000..2326757c --- /dev/null +++ b/src/demo-react/components/gamestate-context.js @@ -0,0 +1,22 @@ +import React, { createContext, useContext, useMemo } from 'react'; + +import { useGameReducer } from '../gamestate/reducer'; + +export const GameStateContext = createContext(); + +export function useGameStateContext() { + return useContext(GameStateContext); +} + +export function GameStateStore({ children }) { + const [state, dispatch] = useGameReducer(); + const contextValue = useMemo(() => { + return { state, dispatch }; + }, [state, dispatch]); + + return ( + + { children } + + ); +} diff --git a/src/demo-react/components/menu.js b/src/demo-react/components/menu.js new file mode 100644 index 00000000..1c04c150 --- /dev/null +++ b/src/demo-react/components/menu.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Box, useInput } from 'ink'; + +import Button from './button'; + +export const MenuEntry = (title, onItemSelected, color) => ({ + color, + title, + onItemSelected +}); + +MenuEntry.propTypes = PropTypes.shape({ + title: PropTypes.string, + onItemSelected: PropTypes.func, + color: PropTypes.string +}); + +export const Menu = ({ isActive, items, onFocusPrevious, width }) => { + const menu = items; + const [selectedIdx, setSelectedIdx] = useState(0); + + const inputHandler = (_, key) => { + if (key.upArrow || key.leftArrow || (key.shift && key.tab)) { + setSelectedIdx(Math.max(0, selectedIdx - 1)); + if (selectedIdx - 1 < 0) { + onFocusPrevious(); + } + } else if (key.downArrow || key.rightArrow || (key.tab)) { + setSelectedIdx(Math.min(menu.length - 1, selectedIdx + 1)); + } else if (key.return) { + const onItemSelected = menu[selectedIdx].onItemSelected; + onItemSelected(); + } + + }; + useInput(inputHandler, { isActive }); + + + return ( + + { + menu.map((menuEntry, idx) => + + ) + } + + ); +} + +Menu.propTypes = { + isActive: PropTypes.bool, + items: PropTypes.arrayOf(MenuEntry.propTypes).isRequired, + onFocusPrevious: PropTypes.func, + width: PropTypes.string +}; + +Menu.defaultProps = { + isActive: true, + onFocusPrevious: () => {} +} diff --git a/src/demo-react/components/number-field.js b/src/demo-react/components/number-field.js new file mode 100644 index 00000000..9ab8e0fa --- /dev/null +++ b/src/demo-react/components/number-field.js @@ -0,0 +1,59 @@ +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { Box, Text, useInput } from 'ink'; + +import action from '../gamestate/generic-action'; + +export default function NumberField({ + actionType, dispatch, children, currentValue, isActive +}) { + const [tempInput, setTempInput] = useState(''); + + const inputHandler = useCallback((input, key) => { + // Allow input that is all digits. + if (/^[0-9]+$/.test(input)) { + setTempInput(curr => curr + input); + } + + if ((key.delete && !key.meta) || key.backspace) { + setTempInput(curr => curr.slice(0, -1)); + } + + if (key.return) { + let stateToCommit = ''; + + setTempInput(curr => { + stateToCommit = curr; + return ''; + }); + + if (stateToCommit) { + dispatch(action(actionType, Number(stateToCommit))); + } + } + }, [setTempInput, dispatch, actionType]); + + useInput(inputHandler, { isActive }); + + return ( + + + { tempInput || currentValue } + + { children } + + ) +} + +NumberField.defaultProps = { + isActive: false +}; + +NumberField.propTypes = { + actionType: PropTypes.string, + children: PropTypes.node, + dispatch: PropTypes.func, + isActive: PropTypes.bool, + currentValue: PropTypes.number +}; diff --git a/src/demo-react/components/timer.js b/src/demo-react/components/timer.js new file mode 100644 index 00000000..5a394e45 --- /dev/null +++ b/src/demo-react/components/timer.js @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; + +import { Text } from 'ink'; + +import * as Actions from '../gamestate/action-types'; + +import { useGameStateContext } from '../components/gamestate-context'; + +export default function GameTimer() { + const { state, dispatch } = useGameStateContext(); + + useEffect(() => { + const tick = () => { + dispatch({ type: Actions.TICK }); + }; + + const timer = setInterval(tick, 1000); + return () => { + clearInterval(timer); + }; + }, [dispatch]); + + let color = 'green'; + + if (state.gameTimer <= 10 && state.gameTimer > 5) { + color = 'yellow'; + } else if (state.gameTimer <= 5) { + color = 'red'; + } + + return ( + Time: { state.gameTimer } + ) +} diff --git a/src/demo-react/docs/adr.md b/src/demo-react/docs/adr.md new file mode 100644 index 00000000..346031f4 --- /dev/null +++ b/src/demo-react/docs/adr.md @@ -0,0 +1,69 @@ +_This document is an Architecture Decision Record (ADR). It is based on the [MADR template](https://adr.github.io/madr/). Learn more about ADRs: https://adr.github.io/_ + +# Rewrite Demo Game for maintainability and to facilitate React understanding + +## Context and Problem Statement + +The demo game for Adagrams has presented some challenges for TAs +in the past few years: + +1. Fixes and featues are difficult to apply. +1. The demo game fundamentally depends on [Vorpal](https://github.com/dthree/vorpal), which is no longer being maintained. +1. Students try to understand the demo game architecture and get discouraged. The architecture suffers from a few issues that make it difficult for even seasoned developers to understand, including: + - There is tight coupling among the Controller and View classes in the game. + - The classes violate the Single Responsibility Principle. + - Most of the interesting functionality is in difficult-to-follow callbacks. + +It would be preferable for this demo game to be architected and written in a way that is less cumbersome for students to detangle and also more desirable for them to emulate. + + +## Decision Drivers + +* Students need to be able to use the demo game to manually test their Adagrams solution. +* Ada staff need to be able to keep the demo game up to date and maintain its tests and functionality. +* The demo game's architecture should match Ada pedagogy and ought to serve as a model for application development that students might reasonably choose to emulate. +* Students should be able to follow the demo game's flow of control with guidance from instructional staff or TAs. +* The volunteer who wrote this thinks it would be fun to write a command line utility that depends on React. + +## Considered Options + +* Refactor the existing Vorpal demo game. +* Rewrite the demo game using [Ink](https://github.com/vadimdemedes/ink). + +## Decision Outcome + +Chosen option: Rewrite the demo game using Ink, because: +- Modifying and refactoring the existing Vorpal-based demo game proved difficult. +- Ink is being actively maintained and relies on React, which is a core part of the Ada curriculum. Vorpal requires effort to learn, and in the context of the Ada curriculum is only useful in this application. + + +### Positive Consequences + +* Students now have an example game written in a framework that they are more likely to be able to emulate and be productive with. Ink has obvious adoption, e.g. it is used by Jest. +* Students who try to understand the programming model, which is React-based, will have an easier time connecting the architecture to concepts they are about to learn. Students who don't find the concepts accessible at this point in the curriculum, but decide to put some effort into exploring this code, may benefit from revisiting the architecture of this app once they've learned some React. +* Staff will not be required to learn Vorpal in order to fix or extend the app. + + + +### Negative Consequences + +* Time spent coding the rewrite +* Fewer examples of non-React JavaScript in the curriculum + + +## More Information + +Here is a deeper discussion of the problem context and challenges presented to maintaining the existing demo game: + +1. Fixes and featues are difficult to apply. The architecture of the app makes bugs hard to investigate. For example, the game offers a round timer in its arguments, but it does not implement this round timer. One of the volunteer TAs (this document's author) [created a one-line fix](https://github.com/mmcknett/js-adagrams/commit/5a4535f7b5212b704fa6a478ba98b75ae67d9ee7) for this behavior, yet it is not obvious from inspecting the code—not even to that TA, upon reviewing the commit—why this fix works. Additionally, the tests for the demo game have sporadically led to student submissions failing even though the tests pass locally. It would be nice to apply the principle of "if it ain't broke, don't fix it." However, it is difficult to tell if the demo game is broke and, should it be determined to **be** broke, how one might fix it. + +1. The demo game fundamentally depends on [Vorpal](https://github.com/dthree/vorpal), which is no longer being maintained. Some forks of Vorpal had been created with the intention of keeping the library maintained, including [moleculerjs/vorpal](https://github.com/moleculerjs/vorpal) and [vorpaljs-reforged](https://github.com/vorpaljs-reforged/vorpal), but these forks also appear to have fallen into inactivity. Keeping Vorpal updated has been necessary in the past to suppress `npm audit` security notices, which have scared and distracted students. At this point, there is no known drop-in replacement for Vorpal. + +1. Each cohort, students look to the demo game as a model for how they oght to write JavaScript. Most, understandably, get discrouaged and give up trying to unravel it. As a student, it is easy to blame yourself for an inability to understand code, especially when it follows a "simple" pattern, as Model View Controller (MVC) is purported to be. The demo game even has classes named Model, View, and Controller! Simple, right? TAs, tutors, or instructors are forced to provide the context that this app's architecture is not simple, and is actually difficult for even seasoned developers to approach with intuition. The difficulty arises from a few issues, including: + 1. Tight coupling among the Controller and View classes in the game. For example, the entrypoint to the game is `Controller.start`, which is called from `demo.js`. All that this function does is call `View.start`. All that *that* function does is log a message, then call `show` on the `Vorpal` singleton in the `view.js` module. Two function calls into the demo game, and a trip to the Vorpal docs is necessary to make any informed inferences on the game's behavior. + + The tight coupling among classes seems to have come from an attempt to strictly adhere to the MVC "pattern." During the game's execution, from the very beginning, the flow of control passes back and forth between `Controller`, which invokes `View` after manipulating the model, and `View` which invokes `Controller`—though it unexpectedly does so via `callbacks` objects. This flow of control likely surprises anyone who looks at [the MVC Wikipedia page](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) for an illustration of the pattern. View and Controller are entirely separate in that illustration. Nonetheless, the connection between the two classes does sensibly arise from the constaints of Vorpal and the need to keep the code relatively simple. Attempting to satisfy the conflicting needs of satisfying Vorpal and satisfying MVC has resulted in classes that are named in a way that defies their true relationship. + + 1. The classes violate the Single Responsibility Principle. `View` handles all responsibilities that seem view-related; `Controller` handles all responsibilities that link the model and the view. The `Model` object is probably doing the best job having a single responsibility. That single responsibility is "manage all of the game state", but because the game state is small and interconnected it is arguably singular. + + 1. Most of the interesting functionality driving the game is relegated to difficult-to-follow callbacks. This is a result of Vorpal's interface, and not necessarily unexpected. One must become familiar with the expectations of a framework in order to modify an application that depends on that framework. Wrapping your mind around Vorpal's asynchronous model is simply a tax you as a developer must pay to reap the framework's benefits. That said, the tightly-coupled, large classes that are named after abstract concepts do nothing to reduce the complexity of following the asynchronous callbacks. They make it harder, adding an extra layer to the abstraction-detangling. diff --git a/src/demo-react/docs/screens-wireframe-1.png b/src/demo-react/docs/screens-wireframe-1.png new file mode 100644 index 00000000..2788c0bf Binary files /dev/null and b/src/demo-react/docs/screens-wireframe-1.png differ diff --git a/src/demo-react/docs/screens-wireframe-2.png b/src/demo-react/docs/screens-wireframe-2.png new file mode 100644 index 00000000..7ea3d7e4 Binary files /dev/null and b/src/demo-react/docs/screens-wireframe-2.png differ diff --git a/src/demo-react/gamestate/README.md b/src/demo-react/gamestate/README.md new file mode 100644 index 00000000..b5c4143d --- /dev/null +++ b/src/demo-react/gamestate/README.md @@ -0,0 +1,17 @@ +# gamestate +This is where you find functions controlling the game's underlying state, which includes things like what screen the game is displaying, how many rounds have been requested, what players have guessed, etc. There are a few types of things here that you will commonly find in code following a [state reducer](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers) pattern: +- Actions +- Reducers +- Selectors + +## Actions +An action is just an object. It's expected to have a `type` field with a string value that tells you what type of action it is. Beyond that, it's completely up to you what's in the action object, but most people put the data it contains in a field called `payload`. The point of an action is to indicate, via its type, which part of the reducer should run and provide whatever data is needed to calculate the next state. + +## Reducers +The `reducer` at the bottom of [reducer.js](./reducer.js) looks complicated. Fundamentally, it's a function that is just like the callback you pass to [Array.reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce). In fact, you can imagine React, under the covers, taking an array of action objects that it has been given via `dispatch`, and literally calling `reduce` on that array using your reducer function. The `reduce` function will use your reducer to calculate the new `state`, starting with `initialState`, for every action that it was provided. (You do have to imagine this, though. React doesn't actually use `reduce`. It needs to prioritize, defer, and occasionally discard work it has done to update state, so the mechanism it uses to loop over actions is more complicated.) Once the actions are applied, you end up with a brand new state object that is the result of previous state and action running through your reducer function. + +### Higher-order reducers +So why is the `reducer` [surrounded by a bunch of other function calls](../gamestate/reducer.js#L151-L160)? These are "higher-order reducers", which wrap the core reducer. They apply the [Decorator Pattern](https://blog.logrocket.com/understanding-javascript-decorators/) to modify the behavior of `gameStateReducer`. For example, `gameStateReducer` will happily set `desiredPlayers` to whatever the action payload's value is. However, it is wrapped in [validateOptionsInput](./options.js), which will swap the action out for an error action if the `SET_DESIRED_PLAYERS` action's payload isn't in a valid range. You could achieve the same result with a big, monolithic reducer function, but using the decorator pattern gives you more flexibility to change the reducer without modifying its existing implementation. (That's the O in [SOLID](https://www.geeksforgeeks.org/solid-principle-in-programming-understand-with-real-life-examples/), open/closed: the reducer is open for extension, but closed for modification.) The separation of higher-order reducers also presents opportunity to test them independently. Say you suspected `validateOptionsInput` were affecting state it should not; you can easily remove it by commenting out two lines and then see if the state is still affected. You can also add unit tests specifically for the higher-order reducer. (Note that the current suite of tests simply tests the final `reducer` function, with the `gameStateReducer` and all of the higher-order reducers wrapping it.) For further exploration of the concept, see: [Customizing Behavior with Higher-Order Reducers](https://redux.js.org/usage/structuring-reducers/reusing-reducer-logic#customizing-behavior-with-higher-order-reducers). + +## Selectors +There aren't many selectors; the purpose of selectors is to take the `state` object and retrieve information from it. Most places in the application just read what they need directly from `state`, but the win screen has some more complicated calculations to do. Those have gone in [WinScreenInfo](./win-selectors.js). diff --git a/src/demo-react/gamestate/action-types.js b/src/demo-react/gamestate/action-types.js new file mode 100644 index 00000000..c40c7bef --- /dev/null +++ b/src/demo-react/gamestate/action-types.js @@ -0,0 +1,12 @@ +export const GUESS = 'guess'; +export const ADD_PLAYER = 'add-player'; +export const TICK = 'tick'; +export const ADVANCE_TURN = 'advance-turn'; +export const ADVANCE_ROUND = 'advance-round'; +export const SET_DESIRED_PLAYERS = 'set-desired-players'; +export const SET_NUMBER_ROUNDS = 'set-number-rounds'; +export const SET_TURN_SECONDS = 'set-turn-seconds'; +export const SET_ERROR = 'set-error'; +export const SWITCH_SCREEN = 'switch-screen'; +export const RESET = 'reset'; +export const REMATCH = 'rematch'; diff --git a/src/demo-react/gamestate/errors.js b/src/demo-react/gamestate/errors.js new file mode 100644 index 00000000..7f084a48 --- /dev/null +++ b/src/demo-react/gamestate/errors.js @@ -0,0 +1,28 @@ +import { SET_ERROR } from './action-types'; + +export class SetErrorAction { + constructor(message) { + this.type = SET_ERROR; + this.payload = message; + } +} + +export function withLastError(wrappedReducer) { + // Intercept any error actions and forward the rest to the wrapped reducer. + return (state, action) => { + switch(action.type) { + case SET_ERROR: + return { + ...state, + lastError: action.payload + }; + default: + // If no error, any new action clears the last error state. + const errorRemovedState = { + ...state, + lastError: '' + }; + return wrappedReducer(errorRemovedState, action); + } + } +} diff --git a/src/demo-react/gamestate/generic-action.js b/src/demo-react/gamestate/generic-action.js new file mode 100644 index 00000000..8b630b0c --- /dev/null +++ b/src/demo-react/gamestate/generic-action.js @@ -0,0 +1,3 @@ +export default function action(type, value) { + return { type, payload: value }; +} diff --git a/src/demo-react/gamestate/options.js b/src/demo-react/gamestate/options.js new file mode 100644 index 00000000..7a1ccfc3 --- /dev/null +++ b/src/demo-react/gamestate/options.js @@ -0,0 +1,52 @@ +import * as Actions from './action-types'; +import { SetErrorAction } from './errors'; + +export function validateOptionsInput(wrappedReducer) { + return (state, action) => { + // If any of the payload input is invalid, throw the current action + // away and instead send an error action. + switch (action.type) { + case Actions.SET_DESIRED_PLAYERS: { + const numPlayers = action.payload; + if (numPlayers < 1 || numPlayers > 4) { + return wrappedReducer(state, new SetErrorAction( + `${numPlayers} is not a valid number of players.` + )); + } + break; + } + case Actions.SET_NUMBER_ROUNDS: { + const numRounds = action.payload; + if (numRounds < 1 || numRounds > 5) { + return wrappedReducer(state, new SetErrorAction( + `${numRounds} is not a valid number of rounds.` + )); + } + break; + } + case Actions.SET_TURN_SECONDS: { + const seconds = action.payload; + if (seconds < 10 || seconds > 60) { + return wrappedReducer(state, new SetErrorAction( + `${seconds} is not a valid number of seconds for each player's turn.` + )); + } + break; + } + case Actions.ADD_PLAYER: { + const name = action.payload; + if (!name) { + return wrappedReducer(state, new SetErrorAction('Enter a name!')); + } + + if (state.players.find(p => p.name === name)) { + return wrappedReducer(state, new SetErrorAction( + `A player named ${name} already exists!` + )) + } + } + } + + return wrappedReducer(state, action); + }; +} diff --git a/src/demo-react/gamestate/reducer.js b/src/demo-react/gamestate/reducer.js new file mode 100644 index 00000000..642ee55d --- /dev/null +++ b/src/demo-react/gamestate/reducer.js @@ -0,0 +1,164 @@ +import { useReducer } from 'react'; + +import * as Actions from './action-types'; +import { withLastError } from './errors'; +import { validateOptionsInput } from './options'; +import { validateGuessInput } from './rules'; +import { ScreenId } from './screens'; +import { withGameTimer } from './timer'; + +import Adagrams from 'demo-react/adagrams-proxy'; + +const GO_STRAIGHT_TO_WIN = false; + +const initialState_real = { + currentScreen: ScreenId.MAIN_MENU, + lastError: "", // The error set by the last action + // in-game props + gameTimer: 15, // seconds + currentHand: Adagrams.drawLetters(), + currentRound: 0, // Starts on first round + currentPlayer: 0, // First player starts as current. + // settings + secondsPerTurn: 25, + desiredPlayers: 2, + roundsPerGame: 3, + players: [], // No players known initially. +}; + +// For debugging, this goes straight to the win screen: +const initialState_straighttoWin = { + currentScreen: ScreenId.WIN, + lastError: "", // The error set by the last action + // in-game props + gameTimer: 5, // seconds + currentHand: Adagrams.drawLetters(), + currentRound: 0, // Starts on first round + currentPlayer: 0, // First player starts as current. + // settings + secondsPerTurn: 5, // Invalid value to set, but I can still set it by default. You can't cage me! + desiredPlayers: 4, + roundsPerGame: 2, + players: [ + { name: 'Max', words: [['HELL', 'LOW'], ['WORLD']]}, + { name: 'Min Soo Jung', words: [[], ['OLE']]}, + { name: 'Alexandria', words: [['HELLO'], ['DROLE', 'ROLE']]}, + { name: 'Jacqueline', words: [['DROOL'], ['ROWL']]} + ], +}; + +export const initialState = GO_STRAIGHT_TO_WIN ? initialState_straighttoWin : initialState_real; + +// TODO: We need a break in between turns! +// TODO: How could we deal with the first player getting more time to think? +function gameStateReducer(state, action) { + switch (action.type) { + case Actions.SWITCH_SCREEN: + return { ...state, currentScreen: action.payload }; + case Actions.SET_NUMBER_ROUNDS: + return { ...state, roundsPerGame: action.payload }; + case Actions.SET_DESIRED_PLAYERS: + return { ...state, desiredPlayers: action.payload }; + case Actions.SET_TURN_SECONDS: + return { ...state, secondsPerTurn: action.payload, gameTimer: action.payload }; + case Actions.ADD_PLAYER: + const newPlayer = { + name: action.payload, + words: [[]] // a list of word for each round, starting with an empty round 1 list + }; + return { ...state, players: [ ...state.players, newPlayer ] }; + case Actions.GUESS: + return guessWord(state, action.payload); + case Actions.ADVANCE_TURN: + return advanceTurn(state); + case Actions.REMATCH: + return { + ...state, + currentScreen: ScreenId.GAME, + currentHand: Adagrams.drawLetters(), + currentRound: 0, + currentPlayer: 0, + players: state.players.map(existingPlayer => ({ + ...existingPlayer, + words: [[]] + })) + }; + case Actions.RESET: + return { + ...initialState + }; + default: + return { ...state }; + } +} + +function advanceTurn(state) { + const { players, currentPlayer, currentRound } = state; + if (players.length === 0) { + return state; + } + + const nextPlayer = (currentPlayer + 1) % players.length; + const nextRound = currentRound + 1; + + // If nextPlayer is the first player... + // and this is the last round, go to the win screen. + // Otherwise advance the round. + // and if nextPlayer isn't the first player, just advance player. + if (nextPlayer === 0) { + if (nextRound >= state.roundsPerGame) { + return { + ...state, + currentScreen: ScreenId.WIN + }; + } else { + return { + ...state, + currentPlayer: 0, + currentRound: nextRound, + currentHand: Adagrams.drawLetters(), + // Add another round of words to each player. + players: state.players.map(p => ({ + name: p.name, + words: [...p.words, []] + })) + } + } + } + + return { + ...state, + currentPlayer: nextPlayer + } +} + +function guessWord(state, word) { + // Add the word to the current player's current round list. + return { + ...state, + players: state.players.map((p, pIdx) => ({ + name: p.name, + words: p.words.map((wordsForRound, wIdx) => { + if (wIdx === state.currentRound && pIdx === state.currentPlayer) { + return [...wordsForRound, word]; + } + return wordsForRound; + }) + })) + }; +} + +export const reducer = + withGameTimer( + validateGuessInput( + validateOptionsInput( + withLastError( + gameStateReducer + ) + ) + ) + ); + +export function useGameReducer() { + return useReducer(reducer, initialState); +} diff --git a/src/demo-react/gamestate/rules.js b/src/demo-react/gamestate/rules.js new file mode 100644 index 00000000..63abb696 --- /dev/null +++ b/src/demo-react/gamestate/rules.js @@ -0,0 +1,42 @@ +import * as Actions from './action-types'; +import { SetErrorAction } from './errors'; + +import Adagrams from 'demo-react/adagrams-proxy'; + +export function validateGuessInput(wrappedReducer) { + return (state, action) => { + if (action.type === Actions.GUESS) { + const word = action.payload.toUpperCase(); + + if (wordHasBeenGuessed(state, word)) { + const errAction = new SetErrorAction(`${word} was already guessed!`); + return wrappedReducer(state, errAction); + } + + if (!word) { + return wrappedReducer(state, new SetErrorAction('Enter a word!')); + } + + if (!Adagrams.usesAvailableLetters(word, state.currentHand)) { + const errAction = new SetErrorAction(`${word} isn't valid!`); + return wrappedReducer(state, errAction); + } + + // Ensure that the word is uppercased + return wrappedReducer(state, { ...action, payload: word }); + } + + return wrappedReducer(state, action); + } +} + +function wordHasBeenGuessed(state, word) { + for (let p of state.players) { + for (let w of p.words[state.currentRound]) { + if (w === word) { + return true; + } + } + } + return false; +} diff --git a/src/demo-react/gamestate/screens.js b/src/demo-react/gamestate/screens.js new file mode 100644 index 00000000..9a18b0da --- /dev/null +++ b/src/demo-react/gamestate/screens.js @@ -0,0 +1,17 @@ +import { SWITCH_SCREEN } from './action-types'; + +export const ScreenId = { + ENTER_PLAYERS: 'enter-players', + HOW_TO: 'how-to', + MAIN_MENU: 'main-menu', + SETUP: 'game-setup', + GAME: 'game', + WIN: 'win' +}; + +export class SwitchScreenAction { + constructor(screenId) { + this.type = SWITCH_SCREEN; + this.payload = screenId; + } +} diff --git a/src/demo-react/gamestate/timer.js b/src/demo-react/gamestate/timer.js new file mode 100644 index 00000000..7aca2e33 --- /dev/null +++ b/src/demo-react/gamestate/timer.js @@ -0,0 +1,24 @@ +import makeAction from './generic-action'; +import { ADVANCE_TURN, TICK } from './action-types'; + +export function withGameTimer(reducer) { + return (state, action) => { + if (action.type !== TICK) { + return reducer(state, action); + } + + // On a tick, do the following: + // 1. If time is left on the clock, decrement the timer. + // 2. If the timer is at 0, advance the turn. + if (state.gameTimer > 1) { + return { + ...state, + gameTimer: state.gameTimer - 1 + }; + } else { + // Advance the turn and reset the game timer. + const turnAdvancedState = reducer(state, makeAction(ADVANCE_TURN)); + return { ...turnAdvancedState, gameTimer: state.secondsPerTurn } + } + } +} diff --git a/src/demo-react/gamestate/win-selectors.js b/src/demo-react/gamestate/win-selectors.js new file mode 100644 index 00000000..f19d9951 --- /dev/null +++ b/src/demo-react/gamestate/win-selectors.js @@ -0,0 +1,45 @@ +import Adagrams from 'demo-react/adagrams-proxy'; + +export class WinScreenInfo { + playerScores; + roundWinners; + + constructor(state) { + this.playerScores = state.players.map( + player => { + const flattenedWords = [].concat(...player.words); + const scores = flattenedWords.map(Adagrams.scoreWord); + const totalScore = scores.reduce((sum, score) => sum + score, 0); + const bestWord = Adagrams.highestScoreFrom(flattenedWords); + return { + name: player.name, + score: totalScore, + bestWord: bestWord && `${bestWord.word} (${bestWord.score})` || '' + }; + } + ); + this.playerScores.sort((a, b) => b.score - a.score); + + this.roundWinners = new Array(state.players[0].words.length); + for (let i = 0; i < this.roundWinners.length; ++i) { + const playerScoresThisRound = state.players.map(player => { + const scores = player.words[i].map(Adagrams.scoreWord); + const roundScore = scores.reduce((sum, score) => sum + score, 0); + return { + score: roundScore, + name: player.name + }; + }); + playerScoresThisRound.sort((a, b) => b.score - a.score); + this.roundWinners[i] = playerScoresThisRound[0]; + } + } + + getWinningScore() { + return this.playerScores[0].score; + } + + getWinner() { + return this.playerScores[0].name; + } +} diff --git a/src/demo-react/screens/README.md b/src/demo-react/screens/README.md new file mode 100644 index 00000000..d031b4ec --- /dev/null +++ b/src/demo-react/screens/README.md @@ -0,0 +1,43 @@ +# Screens +This is the game's "presentation layer". If you think of the app as following the [Model-View-Controller (MVC) pattern](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller), the screens (and the [components](../components/) they depend on) contain the View and Controller. They contain the View in that they render the jsx that results in text showing up on the screen. They contain the Controller in that they register input-handling functions, which dispatch actions, in order to control the state in [gamestate](../gamestate). In fact, had the game's state, which is the Model, been simple enough, they would also have been the place where state is defined and stored. + +## Following MVC +### MVC and the structure of files, functions, and classes +The prior statement is a big hint that "following the MVC pattern" does not mean separating files, or even classes, or even functions (!) into "model", "view", and "controller". Following the MVC pattern means: +1. A user manipulates some input device (mouse, keyboard, touch, microphone, etc.) +1. That input is interpreted by a controller, with enough additional information from the context of the running application, to modify an underlying "model". +1. The change made to the model is observed by a "view" layer, which takes the model changes and renders it into a form that an output device can interpret. +1. The rendered output is displayed to the user via some means (screen, printed document, audio output, tactile display, etc.). + +

+ Expand for a deeper discussion on the nature and value of MVC. + +The model is at the core, and around it, there are many many layers of Controller and View. They make the model available to the user, and allow the user to interact with it. The screens in this folder organize certain aspects of the application's overall View and Controller, segmenting them in a way that matches the developer's mental model of how the application is supposed to behave from a user perspective. The folders, files, and even the functions representing components, emerge out of this mental model of user interaction, rather than being organized around the View and Controller concepts themselves. That means that not every Ink app, and likely very few React apps, would have a "screens" folder. There are only "screen" components because the wireframes started with screens. React apps also wouldn't have "view" and "controller" folders; in React it is awkward to separate views from controllers cleanly. Doing so would make the code harder to understand. The View & Controller layer might have been organized differently, depending on what the original specifications had looked like. + +Likewise, the "game state" model might not have been monolithic, global, and located in a single folder. There are some obvious seams in the game state where the state might be separated into different objects. Were the application to grow, it would benefit from refactoring to separate the state that changes independently, similar to how these screens are factored to display screens that are independent of each other from a View perspective. + +If this leaves you feeling uncomfortable with how loose the MVC concept is as a pattern, you may prefer to instead think about what anti-patterns might emerge in your code, and what benefits you miss out on, if it isn't applied. The most common anti-pattern emerges when the Controller directly interacts with the View. Input directly controls output, without an observable model in between. It makes it harder to keep track of whatever information passes from input to output. It means that testing the system isn't possible without poking buttons and looking at the screen. Forcing data through a Model layer adds a lot of flexbility when it comes to extending and testing a UI system. The most obvious benefit comes from separating the Model from the View and Controller. Separating out a clear, but abstract, representation of the app's state and of the data that a user cares about, makes it much easier to move that state around—keep track of previous state in order to undo actions, send the state to a server or to disk, etc. You can save the state so that the user can resume whatever they were doing if their application closed or disconnected. You can more easily create file saving code. And you can much more easily test your model without having to mock more complicated aspects of code like display or user input. While testing the demo game, the original developer took advantage of this. It has [a debug flag in its reducer](../gamestate/reducer.js#L12) that lets you go straight to a win screen with mock data. This sort of setting would be possible, but more difficult, had the model been spread throughout the application. + +Separating the Model from other aspects of the application is so powerful that "Model" is the common theme when you compare MVC to other patterns that are intended to compete with or supersede it, such as [Model-View-ViewModel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) (MVVM), [Model-View-Adapter](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93adapter) (MVA), and [Model-View-Presenter](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter) (MVP). In fact, if you compare the data flow diagrams of all of these "patterns" to the data flow diagram of [the traditional 3-tier architecture](https://en.wikipedia.org/wiki/Multitier_architecture#Three-tier_architecture), you might come to the conclusion that they're all fundamentally the same. People who discuss and theorize about software architecture seem to come back, again and again, to this idea. There is a model of a system at the center, which is stored in a way the computer can represent it. Changes to that model are organized by some layer that interprets user actions and forms them into functions that can operate on the model. When the model changes, calculations have to be made in order to return the updates back to the user. The various organizational models offer different advice and points of view on how the systems that interpret input or produce output ought to be organized or connected, but fundamentally the abstracted model of data is always at the center. +

+ +TL;DR: An observable model is key, and you have lots of flexibility in organizing the code that handles user input/output. + +### Why screens? +These rough wireframes were used to guide development of the View. The "screens" concept evolved directly from the wireframes: + +![This is a high level description of screens in the Adagrams application. Five screens are listed: Main Menu, How to Play, Set up game (with a name entry sub-screen), In-game, and Win Screen. All of the screens' layouts except for in-game are shown in this wireframe. The "Main Menu" screen has three options: Start New Game, How to Play, and Quit. "Start New Game" is highlighted. The "How to Play" screen provides the following explanation: "Select # and names of players. Each round, a new set of 10 letters is chosen. Every player has a certain number of seconds to guess. The winner is whoever has the highest score at the end of all rounds." The Set up Game screen has three areas to enter numbers: Number of players (1-4), Number of rounds, and Seconds per player per round. It lists initial values of 2, 3, and 15, respectively. 2 is highlighted. The player entry sub-screen of "Set up Game" prompts the user with "Player whatever, enter name". As an example of input, "Matt" is the name entered. The subs-screen is prescribed to show for each of the number of players entered in "Set Up Game". The In-Game screen is part of the next wireframe image and not present. Finally, the Win Screen shows that a message will display after the game is complete. The message is: "Player wins with Number points!", where Player and Number are placeholders for the winner and how many points they earned. Additionally, a list of players and the points they earned appears below.](../docs/screens-wireframe-1.png) +![This second wireframe displays the In-Game screen listed in the first wireframe. Along the top of the screen is an example Adagrams hand: I A H Z R D H E S G. The round number and time remaining are displayed below this. Then, each player name is displayed in its own column, with the first player highlighted. A note points out that the highlight indicates the current guesser. Below the first player, a guess, "DEAR" is shown, along with its score. Below that, a blank area is highlighted, and a note is attached: "Highlight where you're entering words." The second player column has a note that says: "Not allowed to guess another player's word." It also has a note that says: "Start here on next round", which means the wireframes suggest that the round following this one should start with the second, rather than first, player.](../docs/screens-wireframe-2.png) + +It's worth mentioning that the final result does not implement these wireframes exactly. The rough, hand-drawn nature of these wireframes is a big hint that they are not intended to be a full specification, followed rigorously; they are merely a jumping-off point. That is exactly how development played out. + +## Magic index.js +The root component of the screens, `ScreenDisplayer`, is inside of [index.js](./index.js). That lets the import statement in [app.js](../app.js) look a little magical: + +``` +import ScreenDisplayer from './screens'; +``` + +JavaScript `import` knows to look for `index.js` when it's given a folder instead of a file, and that's how `App` ends up getting the `ScreenDisplayer` without mentioning "index" anywhere. + +You can see the screens that have been implemented by looking at the logic inside of `ScreenDisplayer`. diff --git a/src/demo-react/screens/enter-players.js b/src/demo-react/screens/enter-players.js new file mode 100644 index 00000000..2bc01c32 --- /dev/null +++ b/src/demo-react/screens/enter-players.js @@ -0,0 +1,53 @@ +import React, { useState, useCallback, useEffect } from 'react'; + +import { Box, Text } from 'ink'; +import TextInput from 'ink-text-input'; + +import { ScreenId, SwitchScreenAction } from '../gamestate/screens'; +import basicAction from "../gamestate/generic-action"; +import * as Actions from '../gamestate/action-types'; +import { useGameStateContext } from '../components/gamestate-context'; + +export default function EnterPlayers() { + const { state, dispatch } = useGameStateContext(); + const [inputText, setInputText] = useState(''); + + const nextPlayerIdx = state.players.length + 1; + + useEffect(() => { + if (nextPlayerIdx > state.desiredPlayers) { + dispatch(new SwitchScreenAction(ScreenId.GAME)); + } + }, [dispatch, nextPlayerIdx]); + + const handleChange = useCallback((text) => { + setInputText(text); + }, [setInputText]); + + const handleSubmit = useCallback((text) => { + setInputText(''); + + dispatch(basicAction(Actions.ADD_PLAYER, text)); + }, [dispatch, nextPlayerIdx, state.desiredPlayers, setInputText]); + + return ( + + + Enter name for Player { nextPlayerIdx }: + + > + + + + + ); +} diff --git a/src/demo-react/screens/game-setup.js b/src/demo-react/screens/game-setup.js new file mode 100644 index 00000000..cf902b89 --- /dev/null +++ b/src/demo-react/screens/game-setup.js @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { Box, useInput } from 'ink'; + +import { + SET_DESIRED_PLAYERS, + SET_NUMBER_ROUNDS, + SET_TURN_SECONDS +} from '../gamestate/action-types'; +import { ScreenId, SwitchScreenAction } from '../gamestate/screens'; + +import { Menu, MenuEntry } from '../components/menu'; +import NumberField from '../components/number-field'; +import { useGameStateContext } from '../components/gamestate-context'; + +export default function SetupGame() { + const { state, dispatch } = useGameStateContext(); + + const fields = [ SET_DESIRED_PLAYERS, SET_NUMBER_ROUNDS, SET_TURN_SECONDS, 'menu' ]; + const [selectedField, setSelectedField] = useState(SET_DESIRED_PLAYERS); + + useInput((input, key) => { + const idxSelected = fields.indexOf(selectedField); + if (key.return || (!key.shift && key.tab) || key.downArrow || key.rightArrow) { + setSelectedField(fields[idxSelected + 1]); + } else if ((key.shift && key.tab) || key.upArrow || key.leftArrow) { + setSelectedField(fields[Math.max(0, idxSelected - 1)]); + } + }, { isActive: selectedField !== 'menu' }); + + const selectFieldBeforeMenu = () => { + const idxMenu = fields.indexOf('menu'); + setSelectedField(fields[idxMenu - 1]); + } + + const menu = [ + MenuEntry('Enter Names', () => dispatch(new SwitchScreenAction(ScreenId.ENTER_PLAYERS))), + MenuEntry('Go Back', () => dispatch(new SwitchScreenAction(ScreenId.MAIN_MENU))) + ]; + + return ( + + + + Number of players (1-4) + + + Number of rounds (1-5) + + + Seconds per player per round (10-60) + + + + + ) +} diff --git a/src/demo-react/screens/game.js b/src/demo-react/screens/game.js new file mode 100644 index 00000000..e22ac885 --- /dev/null +++ b/src/demo-react/screens/game.js @@ -0,0 +1,110 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Box, Text } from 'ink'; +import TextInput from 'ink-text-input'; + +import basicAction from '../gamestate/generic-action'; +import { GUESS } from '../gamestate/action-types'; +import GameTimer from '../components/timer'; +import { useGameStateContext } from '../components/gamestate-context'; + +import Adagrams from 'demo-react/adagrams-proxy'; + +export default function Game() { + const { state } = useGameStateContext(); + return ( + + + Current hand: + { state.currentHand && state.currentHand.map((letter, idx) => ( + + { letter } + + ))} + + + Round:  + { state.currentRound + 1 /* 1st round is 0 */ } + + + + + { + state.players.map((player, idx) => + + ) + } + + + ) +} + +function PlayerGuesses({ player, round, isActive }) { + const { dispatch } = useGameStateContext(); + const [inputText, setInputText] = useState(''); + + // Reset text input when isActive changes. + useEffect(() => { + setInputText(''); + }, [isActive]) + + const handleChange = useCallback((text) => { + setInputText(text); + }, [setInputText]); + + const handleSubmit = useCallback((text) => { + setInputText(''); + dispatch(basicAction(GUESS, text)); + }, [dispatch, setInputText]); + + return ( + + + {player.name} + + { + player.words[round].map((wordInCurrentRound, idx) => + + {idx + 1}: { wordInCurrentRound } ({ Adagrams.scoreWord(wordInCurrentRound) }) + + ) + } + + + { isActive ? '>' : ' ' } + + + + + ) +} + +PlayerGuesses.propTypes = { + player: PropTypes.shape({ + name: PropTypes.string, + words: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)) + }).isRequired, + isActive: PropTypes.bool, + round: PropTypes.number.isRequired +} diff --git a/src/demo-react/screens/how-to.js b/src/demo-react/screens/how-to.js new file mode 100644 index 00000000..e04451ef --- /dev/null +++ b/src/demo-react/screens/how-to.js @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Menu, MenuEntry } from '../components/menu'; +import { useGameStateContext } from '../components/gamestate-context'; +import { SwitchScreenAction, ScreenId } from '../gamestate/screens'; + +import { Text, Box, Newline } from 'ink'; + +export default function HowTo() { + const { dispatch } = useGameStateContext(); + + const oneButtonMenu = [ + MenuEntry( + 'Go Back', + () => dispatch( + new SwitchScreenAction(ScreenId.MAIN_MENU) + ) + ) + ]; + + return ( + + Select "Start New Game" to play. + + Choose the number of players, rounds, and seconds each person has available for guessing. + Then, enter the name of each player. + + + Each round, a new set of 10 letters will be chosen. Each player has limited time to + form words out of the available letters. + + Whoever has the highest scoring words across all rounds wins the game! + + + + ) +}; diff --git a/src/demo-react/screens/index.js b/src/demo-react/screens/index.js new file mode 100644 index 00000000..46618d49 --- /dev/null +++ b/src/demo-react/screens/index.js @@ -0,0 +1,37 @@ +import React from 'react'; + +import { ScreenId } from '../gamestate/screens'; + +import HowTo from './how-to'; +import MainMenu from './main-menu'; +import SetupGame from './game-setup'; +import EnterPlayers from './enter-players'; +import Game from './game'; +import Win from './win'; +import { useGameStateContext } from '../components/gamestate-context'; +import ErrorViewer from '../components/error-viewer'; + +export default function ScreenDisplayer() { + const { state } = useGameStateContext(); + + let screen = ; + + if (state.currentScreen === ScreenId.HOW_TO) { + screen = ; + } else if (state.currentScreen === ScreenId.SETUP) { + screen = ; + } else if (state.currentScreen === ScreenId.ENTER_PLAYERS) { + screen = ; + } else if (state.currentScreen === ScreenId.GAME) { + screen = ; + } else if (state.currentScreen === ScreenId.WIN) { + screen = ; + } + + return ( + <> + { screen } + + + ); +} diff --git a/src/demo-react/screens/main-menu.js b/src/demo-react/screens/main-menu.js new file mode 100644 index 00000000..573f0707 --- /dev/null +++ b/src/demo-react/screens/main-menu.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Box, useApp } from 'ink'; + +import { useGameStateContext } from '../components/gamestate-context'; +import { Menu, MenuEntry } from '../components/menu'; +import { SwitchScreenAction, ScreenId } from '../gamestate/screens'; + +export default function MainMenu() { + const { exit } = useApp(); + const { dispatch } = useGameStateContext(); + + const mainMenu = [ + MenuEntry('Start New Game', () => dispatch(new SwitchScreenAction(ScreenId.SETUP))), + MenuEntry('How to Play', () => dispatch(new SwitchScreenAction(ScreenId.HOW_TO))), + MenuEntry('Quit', exit, 'red') + ]; + + return ( + + + + ); +} diff --git a/src/demo-react/screens/win.js b/src/demo-react/screens/win.js new file mode 100644 index 00000000..f2466d56 --- /dev/null +++ b/src/demo-react/screens/win.js @@ -0,0 +1,81 @@ +import React from 'react'; + +import { Box, Newline, Text, useApp } from 'ink'; + +import * as Actions from '../gamestate/action-types'; +import { WinScreenInfo } from '../gamestate/win-selectors'; +import { Menu, MenuEntry } from '../components/menu'; +import { useGameStateContext } from '../components/gamestate-context'; + +export default function Win() { + const { state, dispatch } = useGameStateContext(); + const { exit } = useApp(); + const menu = [ + MenuEntry('Rematch!', () => dispatch({ type: Actions.REMATCH })), + MenuEntry('Start Over', () => dispatch({ type: Actions.RESET })), + MenuEntry('Quit', exit, 'red') + ]; + + const scores = new WinScreenInfo(state); + const playerScores = scores.playerScores; + const roundWinners = scores.roundWinners; + const winningScore = scores.getWinningScore(); + const winner = scores.getWinner(); + + return ( + + + + { winner } + wins with a score of  + { winningScore }! + + + + + Best words: + { + playerScores.map(p => { p.name }: { p.bestWord }) + } + + + Leaderboard: + { + playerScores.map(p => { p.name }: { p.score }) + } + + + Round Winners: + { + roundWinners.map((p, i) => Round { i + 1 }: { p.name } ({ p.score })) + } + + + + + + + ); +} diff --git a/src/demo.js b/src/demo.js deleted file mode 100644 index 3f8a67f2..00000000 --- a/src/demo.js +++ /dev/null @@ -1,7 +0,0 @@ -import Model from 'demo/model'; -import View from 'demo/view'; -import Controller from 'demo/controller'; - -// Initialize the controller I guess -const game = new Controller(Model, View); -game.start(); diff --git a/src/demo/adagrams.js b/src/demo/adagrams.js deleted file mode 100644 index 135abd7f..00000000 --- a/src/demo/adagrams.js +++ /dev/null @@ -1,56 +0,0 @@ -import { - drawLetters, - usesAvailableLetters, - scoreWord, - highestScoreFrom, -} from "adagrams"; - -const Real = { - drawLetters, - usesAvailableLetters, - scoreWord, - highestScoreFrom, -}; - -const Stub = { - drawLetters() { - const defaultLetters = ["H", "E", "L", "L", "O", "W", "O", "R", "L", "D"]; - - if (typeof Real.drawLetters === "function") { - return Real.drawLetters() || defaultLetters; - } - - return defaultLetters; - }, - - usesAvailableLetters(input, lettersInHand) { - if (typeof Real.usesAvailableLetters === "function") { - return Real.usesAvailableLetters(input, lettersInHand); - } - - return true; - }, - - scoreWord(word) { - if (typeof Real.scoreWord === "function") { - return Real.scoreWord(word); - } - - return -1; - }, - - highestScoreFrom(words) { - if (typeof Real.highestScoreFrom === "function") { - return Real.highestScoreFrom(words); - } - - if (words.length < 1) return null; - - return { - word: words[0], - score: Stub.scoreWord(words[0]), - }; - }, -}; - -export default Stub; diff --git a/src/demo/controller.js b/src/demo/controller.js deleted file mode 100644 index 4360699a..00000000 --- a/src/demo/controller.js +++ /dev/null @@ -1,61 +0,0 @@ -class Controller { - constructor(model, view) { - this.model = model; - this.view = view; - - this.view.init({ - play: this.play.bind(this), - exit: this.exit.bind(this), - }); - } - - start() { - this.view.start(); - } - - play(players, rounds = 3, time = 60) { - // Create a new game instance - this.game = new this.model({ - players, rounds, time - }); - - this.view.newGame(this.game); - - // Advance to the first round - this.advanceRound(); - } - - advanceRound() { - const gameState = this.game.nextRound(); - if(gameState.gameOver) { - this.view.gameOver(gameState); - } else { - this.view.newRound(this.game); - this.startTurn(); - } - } - - startTurn() { - this.view.playerTurn(this.game, { - playWord: this.game.playWord.bind(this.game), - endTurn: this.endTurn.bind(this), - }); - } - - endTurn() { - // Advance to next player - const roundState = this.game.nextTurn(); - const callback = (roundState.roundOver - ? this.advanceRound - : this.startTurn).bind(this); - - return { roundState, callback }; - } - - exit() { - this.view.exit(); - process.exit(); - } -} - -export default Controller; diff --git a/src/demo/messages.js b/src/demo/messages.js deleted file mode 100644 index 6c1d24e5..00000000 --- a/src/demo/messages.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - intro: 'Welcome to Adagrams!', - exit: 'Thank you for playing Adagrams!', - - newGame: 'Starting a new game of Adagrams...', - newRound: (curr, total) => `Round ${curr} of ${total}:`, - playWordSuccess: (word, score) => `Played ${word} for ${score} points.`, - playWordFailure: (word) => `Invalid word: ${word}.`, - roundOver: (winner) => `Round over! The winner of this round is ${winner.player} who got ${winner.word} for ${winner.score} points!`, - gameOver: (winner) => `Game over! Our winner is.... ${winner.player} with a total score of ${winner.score}!`, -}; diff --git a/src/demo/model.js b/src/demo/model.js deleted file mode 100644 index 1a0ba47b..00000000 --- a/src/demo/model.js +++ /dev/null @@ -1,140 +0,0 @@ -import Adagrams from 'demo/adagrams'; - -class Model { - constructor(config) { - if(!config) { - throw new Error('Model requires a config parameter.'); - } - - this.config = config; - - // Initialize game state - this.round = 0; - this.currentPlayer = null; - this.letterBank = null; - - /* Plays history structure is: - { - playerOne: [ - ["APPLE", "PAPA", "LEAP"], // round 1 - ["WALK", "WALKER", "RAKE"], // round 2 - ], - - playerTwo: [ - ["PALE", "PELT"], // round 1 - ["REAL", "WALTER", "TALKER"], // round 2 - ], - } - */ - this.plays = this.config.players.reduce((plays, player) => { - plays[player] = []; - return plays; - }, {}); - } - - currentPlayerName() { - if(this.currentPlayer === null) return null; - - return this._playerName(this.currentPlayer); - } - - // Start the next round of the game - nextRound() { - this.round++; - this.currentPlayer = 0; - - const gameOver = this.round > this.config.rounds; - if(gameOver) { - return { gameOver, winner: this._gameWinner() }; - } - - // Draw the letter bank - this.letterBank = Adagrams.drawLetters(); - - // Initialize player history for this round - this.config.players.forEach((player) => { - this.plays[player][this.round - 1] = []; - }); - - return { gameOver, winner: null }; - } - - nextTurn() { - this.currentPlayer++; - - const roundOver = this.currentPlayer >= this.config.players.length; - const winner = !roundOver ? null : this._roundWinner(this.round); - - return { roundOver, winner }; - } - - playWord(word) { - word = word.toUpperCase(); - - if(!this._valid(word)) return null; - - this._recordPlay(word); - - return Adagrams.scoreWord(word); - } - - _valid(word, letterBank = this.letterBank) { - if(word.length < 1) return false; - return Adagrams.usesAvailableLetters(word, letterBank); - } - - _playerName(player) { - return this.config.players[player]; - } - - _recordPlay(word, player = this.currentPlayer, round = this.round) { - this.plays[this._playerName(player)][round - 1].push(word); - } - - _bestPlay(round, player) { - const plays = this.plays[player][round - 1]; - if(plays.length < 1) { - return null; - } - - return Adagrams.highestScoreFrom(plays); - } - - _roundWinner(round) { - const bestPlays = this.config.players - .map((player) => ({ player, ...this._bestPlay(round, player) })) - .filter(({ player, word, score }) => word !== undefined); - - if(bestPlays.length < 1) { - return { player: '', word: '', score: 0 }; - } - - const { word: winningWord } = Adagrams.highestScoreFrom(bestPlays.map(({ word }) => word)); - return bestPlays.find(({ word }) => word === winningWord); - } - - _gameWinner() { - // Add up the scores for each player, counting only the rounds where they won - const roundWinners = []; - for(let round = 1; round <= this.config.rounds; round++) { - const winner = this._roundWinner(round); - const existing = roundWinners.find(({ player }) => player === winner.player); - - if(existing) { - existing.score += winner.score; - } else { - roundWinners.push(winner); - } - } - - return roundWinners.reduce((gameWinner, roundWinner) => { - if(roundWinner.score > gameWinner.score) { - gameWinner = roundWinner; - } - - return gameWinner; - }, { player: '', score: 0 }); - } -} - -export default Model; diff --git a/src/demo/view.js b/src/demo/view.js deleted file mode 100644 index 4fdc2096..00000000 --- a/src/demo/view.js +++ /dev/null @@ -1,126 +0,0 @@ -import Vorpal from 'vorpal'; -import MESSAGES from 'demo/messages.js'; - -const menu = new Vorpal(); - -const View = { - start(play, exit) { - menu.log(MESSAGES.intro); - - menu.show(); - }, - - newGame(model) { - menu.log(MESSAGES.newGame); - }, - - newRound(model) { - menu.log(MESSAGES.newRound(model.round, model.config.rounds)); - }, - - playerTurn(model, callbacks) { - const game = new Vorpal(); - game - .delimiter('') - .show(); - - game.log(model.letterBank.join(' ')); - - game - .mode('playerTurn') - .delimiter(`Now playing: ${model.currentPlayerName()}>`) - .action((word, done) => { - const result = callbacks.playWord(word); - - if(Number.isInteger(result)) { - game.log(MESSAGES.playWordSuccess(word, result)); - } else { - game.log(MESSAGES.playWordFailure(word)); - } - - done(); - }); - - game.exec('playerTurn'); - - // Player's turn is over when the mode exits - game.on('mode_exit', () => { - const {roundState, callback} = callbacks.endTurn(); - if(roundState.roundOver) { - menu.log(MESSAGES.roundOver(roundState.winner)); - game.hide(); - } - - // In order to let this turn finish and the Vorpal object to get GC'd - // we should enqueue the callback rather than run it now - setTimeout(callback, 0); - }); - }, - - gameOver(gameState) { - menu.log(MESSAGES.gameOver(gameState.winner)); - menu.show(); - }, - - exit() { - menu.log(MESSAGES.exit); - }, - - init(callbacks) { - menu.delimiter('Adagrams>'); - - menu - .command('start [players]') - .description('Play a game of Adagrams. ') - .alias('play', 's', 'p') - .option('-r, --rounds ', 'Number of rounds') - .option('-t, --time ', 'Time for each round in seconds') - .action((args, done) => { - const numPlayers = args.players; - const rounds = args.options.rounds; - const time = args.options.time; - - // Player name selection - const validName = (name) => /\w/.test(name.trim()); - - let questions; - if(!Number.isInteger(numPlayers) || numPlayers < 2) { - // Default to a solo game - questions = [{ - name: 'player0', - message: 'Player Name: ', - validate: validName - }]; - } else { - questions = [...Array(numPlayers).keys()].map((n) => ({ - name: `player${n}`, - message: `Player ${n + 1} Name: `, - validate: validName - })); - } - - menu.activeCommand.prompt(questions, (answers) => { - const PROP_REGEX = /^player(\d+)$/; - - let playerNames = - Object.entries(answers) - .map(([prop, val]) => [PROP_REGEX.exec(prop), val.trim()]) - .filter(([match, val]) => !!match) - .sort(([[_, leftNum]], [[__, rightNum]]) => Number(leftNum) - Number(rightNum)) - .map(([_, name]) => name); - - callbacks.play(playerNames, rounds, time); - done(); - }); - }); - - menu - .find('exit') - .action((_, done) => { - callbacks.exit(); - done(); - }); - }, -}; - -export default View; diff --git a/test/demo-react/__snapshots__/app.test.js.snap b/test/demo-react/__snapshots__/app.test.js.snap new file mode 100644 index 00000000..8474d558 --- /dev/null +++ b/test/demo-react/__snapshots__/app.test.js.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Adagrams App Highlights quit on two right-arrow presses 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ╭────────────────╮ ╭─────────────╮ ╭──────╮ │ +│ │ Start New Game │ │ How to Play │ │ Quit │ │ +│ ╰────────────────╯ ╰─────────────╯ ╰──────╯ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`Adagrams App Highlights the next item on right-arrow press 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ╭────────────────╮ ╭─────────────╮ ╭──────╮ │ +│ │ Start New Game │ │ How to Play │ │ Quit │ │ +│ ╰────────────────╯ ╰─────────────╯ ╰──────╯ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`Adagrams App Quits if quit is selected 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ╭────────────────╮ ╭─────────────╮ ╭──────╮ │ +│ │ Start New Game │ │ How to Play │ │ Quit │ │ +│ ╰────────────────╯ ╰─────────────╯ ╰──────╯ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`Adagrams App Renders correctly in the default state 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ╭────────────────╮ ╭─────────────╮ ╭──────╮ │ +│ │ Start New Game │ │ How to Play │ │ Quit │ │ +│ ╰────────────────╯ ╰─────────────╯ ╰──────╯ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`Adagrams App Shows the help screen 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Select \\"Start New Game\\" to play. │ +│ │ +│ │ +│ Choose the number of players, rounds, and seconds each person has available for guessing. │ +│ Then, enter the name of each player. │ +│ │ +│ │ +│ Each round, a new set of 10 letters will be chosen. Each player has limited time to form words │ +│ out of the available letters. │ +│ Whoever has the highest scoring words across all rounds wins the game! │ +│ │ +│ │ +│ ╭─────────╮ │ +│ │ Go Back │ │ +│ ╰─────────╯ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/test/demo-react/app.test.js b/test/demo-react/app.test.js new file mode 100644 index 00000000..86fda44e --- /dev/null +++ b/test/demo-react/app.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import App from 'demo-react/app'; +import { render } from 'ink-testing-library'; +import { expectRenderToMatchSnapshot } from './expect-render'; + +const rightArrow = (renderResult, jsx) => { + const { stdin, rerender } = renderResult; + stdin.write('\u001B[C'); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. +} +const enter = stdin => stdin.write('\r'); + +describe('Adagrams App', () => { + it('Renders correctly in the default state', () => { + expectRenderToMatchSnapshot(); + }); + + it('Highlights the next item on right-arrow press', () => { + const jsx = ; + const rr = render(jsx); + rr.rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + rightArrow(rr, jsx); + + expect(rr.lastFrame()).toMatchSnapshot(); + }); + + it('Highlights quit on two right-arrow presses', () => { + const jsx = ; + const rr = render(jsx); + rr.rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + rightArrow(rr, jsx); + rightArrow(rr, jsx); + + expect(rr.lastFrame()).toMatchSnapshot(); + }); + + it('Quits if quit is selected', () => { + const jsx = ; + const rr = render(jsx); + rr.rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + rightArrow(rr, jsx); + rightArrow(rr, jsx); + enter(rr.stdin); + + expect(rr.lastFrame()).toMatchSnapshot(); + }); + + it('Shows the help screen', () => { + const jsx = ; + const rr = render(jsx); + rr.rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + rightArrow(rr, jsx); + enter(rr.stdin); + + expect(rr.lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/test/demo-react/components/__snapshots__/button.test.js.snap b/test/demo-react/components/__snapshots__/button.test.js.snap new file mode 100644 index 00000000..efc4e7e6 --- /dev/null +++ b/test/demo-react/components/__snapshots__/button.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Button rendering Renders correctly when given a color 1`] = ` +" ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Selected │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Button rendering Renders correctly when not selected 1`] = ` +" ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Not Selected │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Button rendering Renders correctly when selected 1`] = ` +" ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Selected │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Button rendering Renders correctly when selected and given a color 1`] = ` +" ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Selected │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/test/demo-react/components/__snapshots__/number-field.test.js.snap b/test/demo-react/components/__snapshots__/number-field.test.js.snap new file mode 100644 index 00000000..3192d5ec --- /dev/null +++ b/test/demo-react/components/__snapshots__/number-field.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NumberField renders correctly 1`] = ` +" + 5 Description of Field +" +`; + +exports[`NumberField renders correctly when active 1`] = ` +" + 5 Description of Field +" +`; diff --git a/test/demo-react/components/button.test.js b/test/demo-react/components/button.test.js new file mode 100644 index 00000000..d7428fa6 --- /dev/null +++ b/test/demo-react/components/button.test.js @@ -0,0 +1,21 @@ +import React from 'react'; +import Button from 'demo-react/components/button'; +import { expectRenderToMatchSnapshot } from '../expect-render'; + +describe('Button rendering', () => { + it('Renders correctly when not selected', () => { + expectRenderToMatchSnapshot(); + }); + + it('Renders correctly when selected', () => { + expectRenderToMatchSnapshot(); + }); + + it('Renders correctly when given a color', () => { + expectRenderToMatchSnapshot(); + }); + + it('Renders correctly when selected and given a color', () => { + expectRenderToMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/test/demo-react/components/number-field.test.js b/test/demo-react/components/number-field.test.js new file mode 100644 index 00000000..3bd54157 --- /dev/null +++ b/test/demo-react/components/number-field.test.js @@ -0,0 +1,97 @@ +import React from 'react'; +import NumberField from 'demo-react/components/number-field'; +import { expectRenderToMatchSnapshot } from '../expect-render'; +import { render } from 'ink-testing-library'; + +describe('NumberField', () => { + it('renders correctly', () => { + const jsx = Description of Field; + const { lastFrame } = render(jsx); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly when active', () => { + const jsx = Description of Field; + const { lastFrame } = render(jsx); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('accepts input', () => { + const jsx = Description of Field; + const { stdin, rerender, lastFrame } = render(jsx); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + stdin.write('6'); + + expect(lastFrame()).toContain('6'); + }); + + it('ignores input when inactive', () => { + const jsx = Description of Field; + const { stdin, rerender, lastFrame } = render(jsx); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + stdin.write('6'); + + expect(lastFrame()).toContain('5'); + }); + + it('supports backspace (mac delete)', () => { + const jsx = Description of Field; + const { stdin, rerender, lastFrame } = render(jsx); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + stdin.write('6'); + stdin.write('\u007F'); + stdin.write('8'); + + expect(lastFrame()).not.toContain('6'); + expect(lastFrame()).toContain('8'); + }); + + it('supports backspace', () => { + const jsx = Description of Field; + const { stdin, rerender, lastFrame } = render(jsx); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + stdin.write('6'); + stdin.write('\u0008'); + stdin.write('8'); + + expect(lastFrame()).not.toContain('6'); + expect(lastFrame()).toContain('8'); + }); + + it('commits the string on return', () => { + const dispatch = jest.fn(); + const jsx = ( + + Description of Field + + ); + const { stdin, rerender, lastFrame } = render(jsx); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + stdin.write('6'); + stdin.write('\r'); + + expect(dispatch).toHaveBeenCalledWith({ type: 'any-action', payload: 6 }); + }); + + it('supports multiple characters at once (paste)', () => { + const jsx = Description of Field; + const { stdin, rerender, lastFrame } = render(jsx); + rerender(jsx); // Hack: Force React/Ink to run useEffect handlers so the useInput callback gets registered. + + stdin.write('700'); + + expect(lastFrame()).toContain('700'); + }); +}); \ No newline at end of file diff --git a/test/demo-react/expect-render.js b/test/demo-react/expect-render.js new file mode 100644 index 00000000..1f426b3c --- /dev/null +++ b/test/demo-react/expect-render.js @@ -0,0 +1,6 @@ +import { render } from 'ink-testing-library'; + +export function expectRenderToMatchSnapshot(jsx) { + const { lastFrame } = render(jsx); + expect(lastFrame()).toMatchSnapshot(); +} diff --git a/test/demo-react/gamestate/reducer.test.js b/test/demo-react/gamestate/reducer.test.js new file mode 100644 index 00000000..0ccb30ea --- /dev/null +++ b/test/demo-react/gamestate/reducer.test.js @@ -0,0 +1,433 @@ +import { initialState, reducer } from 'demo-react/gamestate/reducer'; +import * as Actions from 'demo-react/gamestate/action-types'; +import makeAction from 'demo-react/gamestate/generic-action'; +import { ScreenId } from 'demo-react/gamestate/screens'; + +import Adagrams from 'demo-react/adagrams-proxy'; + +jest.mock('demo-react/adagrams-proxy', () => { + return { + drawLetters: jest.fn(() => ["H", "E", "L", "L", "O", "W", "O", "R", "L", "D"]), + usesAvailableLetters: jest.fn(() => true), + scoreWord: jest.fn(), + highestScoreFrom: jest.fn(), + }; +}); + +// Some actions merely replace the value of a specific state key with the +// action payload. This is a generic function to test those states. +function assertSimpleActionUpdatesKey(actionType, keyName, value = 'any-value') { + const action = makeAction(actionType, value); + const expected = { + ...initialState, + [keyName]: value + } + + const actual = reducer(initialState, action); + + expect(actual).toEqual(expected); +} + +const mockPlayerList = [ + { name: 'Max', words: [['HELO', 'LOW'], ['WORLD']]}, + { name: 'Min Soo', words: [[], ['OLE']]}, + { name: 'Rupa', words: [['HELLO'], ['DROLL', 'ROLE']]}, + { name: 'Elliot', words: [['DROOL'], ['WHORL']]} +]; + +const inGameState = { + currentScreen: ScreenId.GAME, + lastError: "", // The error set by the last action + // in-game props + gameTimer: 5, // seconds + currentHand: ["H", "E", "L", "O", "L", "W", "O", "R", "L", "D"], + currentRound: 1, // We're on the second round + currentPlayer: 3, // The last player is guessing. + // settings + secondsPerTurn: 60, + desiredPlayers: mockPlayerList.length, + roundsPerGame: 2, + players: mockPlayerList +}; + + +describe('Game state reducer', () => { + describe('Simple actions', () => { + test('Switch screen', () => { + assertSimpleActionUpdatesKey( + Actions.SWITCH_SCREEN, 'currentScreen'); + }); + + test('Reset returns to the initial state', () => { + const action = makeAction(Actions.RESET); + const currentState = { + ...initialState, + currentScreen: ScreenId.WIN, + currentRound: 3, + currentPlayer: 3, + players: mockPlayerList + }; + + const actual = reducer(currentState, action); + + expect(actual).toEqual(initialState); + }) + }); + + describe('Set number of rounds', () => { + test('Set 1 round, succeeds', () => { + assertSimpleActionUpdatesKey( + Actions.SET_NUMBER_ROUNDS, 'roundsPerGame', 1); + }); + + test('Set 5 rounds, succeeds', () => { + assertSimpleActionUpdatesKey( + Actions.SET_NUMBER_ROUNDS, 'roundsPerGame', 5); + }); + + test('Set 0 rounds, sets error', () => { + const action = makeAction(Actions.SET_NUMBER_ROUNDS, 0); + const actual = reducer(initialState, action); + expect(actual).toEqual({ + ...initialState, + lastError: '0 is not a valid number of rounds.' + }); + }); + + test('Set 6 rounds, sets error', () => { + const action = makeAction(Actions.SET_NUMBER_ROUNDS, 6); + const actual = reducer(initialState, action); + expect(actual).toEqual({ + ...initialState, + lastError: '6 is not a valid number of rounds.' + }); + }); + }); + + describe('Set desired number of players', () => { + test('Set desired number of players to 1, succeeds', () => { + assertSimpleActionUpdatesKey( + Actions.SET_DESIRED_PLAYERS, 'desiredPlayers', 1); + }); + + test('Set desired number of players to 4, succeeds', () => { + assertSimpleActionUpdatesKey( + Actions.SET_DESIRED_PLAYERS, 'desiredPlayers', 4); + }); + + test('Set desired number of players to 0, sets error', () => { + const action = makeAction(Actions.SET_DESIRED_PLAYERS, 0); + const actual = reducer(initialState, action); + expect(actual).toEqual({ + ...initialState, + lastError: '0 is not a valid number of players.' + }); + }); + + test('Set desired number of players to 5, sets error', () => { + const action = makeAction(Actions.SET_DESIRED_PLAYERS, 5); + const actual = reducer(initialState, action); + expect(actual).toEqual({ + ...initialState, + lastError: '5 is not a valid number of players.' + }); + }); + }); + + describe('Set number of seconds per turn', () => { + test('Set 10 seconds per turn, succeeds', () => { + const action = makeAction(Actions.SET_TURN_SECONDS, 10); + const expected = { + ...initialState, + gameTimer: 10, + secondsPerTurn: 10 + } + + const actual = reducer(initialState, action); + + expect(actual).toEqual(expected); + }); + + test('Set 60 seconds per turn, succeeds', () => { + const action = makeAction(Actions.SET_TURN_SECONDS, 60); + const expected = { + ...initialState, + gameTimer: 60, + secondsPerTurn: 60 + } + + const actual = reducer(initialState, action); + + expect(actual).toEqual(expected); + }); + + test('Set 9 seconds per turn, sets error', () => { + const action = makeAction(Actions.SET_TURN_SECONDS, 9); + const expected = { + ...initialState, + lastError: "9 is not a valid number of seconds for each player's turn." + } + + const actual = reducer(initialState, action); + + expect(actual).toEqual(expected); + }); + + test('Set 61 seconds per turn, sets error', () => { + const action = makeAction(Actions.SET_TURN_SECONDS, 61); + const expected = { + ...initialState, + lastError: "61 is not a valid number of seconds for each player's turn." + } + + const actual = reducer(initialState, action); + + expect(actual).toEqual(expected); + }); + }); + + describe('Add Player', () => { + test('Add player adds the player and a guess list for the first round', () => { + const expectedState = { + ...initialState, + players: [{ name: 'Player One', words: [[]] }] + }; + const addPlayerOne = makeAction(Actions.ADD_PLAYER, 'Player One'); + + const actual = reducer(initialState, addPlayerOne); + + expect(actual).toEqual(expectedState) + }); + + test('Add player with no name, sets error', () => { + const expectedState = { + ...initialState, + lastError: 'Enter a name!' + }; + const addPlayerOne = makeAction(Actions.ADD_PLAYER, ''); + + const actual = reducer(initialState, addPlayerOne); + + expect(actual).toEqual(expectedState) + }); + + test('Add player with existing player\'s name, sets error', () => { + const expectedState = { + ...initialState, + players: [{ name: 'Player One', words: [[]] }], + lastError: 'A player named Player One already exists!' + }; + const addPlayerOne = makeAction(Actions.ADD_PLAYER, 'Player One'); + + let actual = reducer(initialState, addPlayerOne); + actual = reducer(actual, addPlayerOne); + + expect(actual).toEqual(expectedState) + }); + }); + + describe('Error handling', () => { + test('Set last error', () => { + assertSimpleActionUpdatesKey(Actions.SET_ERROR, 'lastError'); + }); + + test('Any action clears last error', () => { + const currentState = { ...initialState, lastError: 'Some error' }; + const expected = { ...currentState, lastError: '' }; + const action = makeAction( + 'literally-anything-besides-set-error', + 'the-payload' + ); + + const actual = reducer(currentState, action); + + expect(actual).toEqual(expected); + }); + }); + + describe('Timer ticks', () => { + test('Ticks at 2 or more, gameTimer decrements', () => { + const tickAction = makeAction(Actions.TICK); + + const actual = reducer(inGameState, tickAction); + + expect(actual).toEqual({ + ...inGameState, + gameTimer: 4 + }); + }); + + test('Ticks at 1 or fewer, turn advances and gameTimer resets', () => { + const beforeNextTurnState = { + ...inGameState, + currentPlayer: 0, + gameTimer: 1 + }; + const tickAction = makeAction(Actions.TICK); + + const actual = reducer(beforeNextTurnState, tickAction); + + expect(actual).toEqual({ + ...inGameState, + currentPlayer: 1, + gameTimer: inGameState.secondsPerTurn + }); + }); + + test('Ticks at 1 or fewer, no players, only game timer resets', () => { + const beforeNextTurnState = { + ...inGameState, + currentPlayer: 0, + gameTimer: 1, + players: [] + }; + const tickAction = makeAction(Actions.TICK); + const expectedState = { + ...beforeNextTurnState, + gameTimer: inGameState.secondsPerTurn + }; + + const actual = reducer(beforeNextTurnState, tickAction); + + expect(actual).toEqual(expectedState); + }); + + test('Ticks at 1 or fewer, current player is last, round advances and gameTimer resets', () => { + const beforeNextTurnState = { + ...inGameState, + currentPlayer: inGameState.players.length - 1, + currentRound: 0, + gameTimer: 1 + }; + const tickAction = makeAction(Actions.TICK); + + const actual = reducer(beforeNextTurnState, tickAction); + + // The reducer will automatically draw a new hand. + // For testing, just make sure the round, player, and timer are what is + // expected.) + expect(actual).toMatchObject({ + currentPlayer: 0, + currentRound: 1, + gameTimer: inGameState.secondsPerTurn + }); + + // Also check that the hand is different. The test mock is distinct from + // the stub, and it should be nearly impossible to randomly draw the + // previous hand. + expect(actual.currentHand).not.toEqual(beforeNextTurnState.currentHand); + }); + + test('Ticks at 1 or fewer, last round, last player, game moves to WIN screen', () => { + const beforeNextTurnState = { + ...inGameState, + currentPlayer: 3, + currentRound: inGameState.roundsPerGame, + gameTimer: 1 + }; + const tickAction = makeAction(Actions.TICK); + const expectedState = { + ...beforeNextTurnState, + currentScreen: ScreenId.WIN, + gameTimer: 60 + }; + + const actual = reducer(beforeNextTurnState, tickAction); + + expect(actual).toEqual(expectedState); + }); + }); + + describe('Word guessing', () => { + const deepCopyPlayerList = () => { + return mockPlayerList.map(player => ({ ...player, words: player.words.map(roundList => [ ...roundList ]) })); + } + + test('Guessing a word guesses the word for the current player', () => { + const guessWhole = makeAction(Actions.GUESS, 'WHOLE'); + const playerListCopy = deepCopyPlayerList(); + playerListCopy[inGameState.currentPlayer].words[inGameState.currentRound].push('WHOLE'); + const expectedState = { ...inGameState, players: playerListCopy }; + + const actualState = reducer(inGameState, guessWhole); + + expect(actualState).toEqual(expectedState); + }); + + test('Guessing a lower-case word guesses the uppercase word', () => { + const guessWhole = makeAction(Actions.GUESS, 'whole'); + const playerListCopy = deepCopyPlayerList(); + playerListCopy[inGameState.currentPlayer].words[inGameState.currentRound].push('WHOLE'); + const expectedState = { ...inGameState, players: playerListCopy }; + + const actualState = reducer(inGameState, guessWhole); + + expect(actualState).toEqual(expectedState); + }); + + test('Guessing an invalid word sets an error about the word being invalid', () => { + Adagrams.usesAvailableLetters.mockReturnValueOnce(false); + const guessHippo = makeAction(Actions.GUESS, 'HIPPO'); + const expectedState = { ...inGameState, + lastError: "HIPPO isn't valid!" + }; + + const actualState = reducer(inGameState, guessHippo); + + expect(actualState).toEqual(expectedState); + }); + + test('Guessing a word used this round sets an error about duplicate guesses', () => { + const guessWorld = makeAction(Actions.GUESS, 'WORLD'); + const expectedState = { ...inGameState, + lastError: "WORLD was already guessed!" + }; + + const actualState = reducer(inGameState, guessWorld); + + expect(actualState).toEqual(expectedState); + }); + + test('Guessing a word used in a previous round does NOT set an error about duplicate guesses', () => { + const guessHello = makeAction(Actions.GUESS, 'HELLO'); + const playerListCopy = deepCopyPlayerList(); + playerListCopy[inGameState.currentPlayer].words[inGameState.currentRound].push('HELLO'); + const expectedState = { ...inGameState, players: playerListCopy }; + + const actualState = reducer(inGameState, guessHello); + + expect(actualState).toEqual(expectedState); + }); + + test('Guessing an empty word sets an error about a guess being required', () => { + const guessEmptyWord = makeAction(Actions.GUESS, ''); + const expectedState = { ...inGameState, + lastError: "Enter a word!" + }; + + const actualState = reducer(inGameState, guessEmptyWord); + + expect(actualState).toEqual(expectedState); + }); + }); + + describe('Rematch', () => { + test('Invoking a rematch resets the game with the same parameters', () => { + const guessWhole = makeAction(Actions.REMATCH); + const resetPlayerList = mockPlayerList.map(player => ({ ...player, words: [[]] })); + const expectedState = { ...inGameState, + players: resetPlayerList, + currentPlayer: 0, + currentRound: 0 + }; + + const actualState = reducer(inGameState, guessWhole); + + // Ignore currentHand. + delete expectedState.currentHand; + delete actualState.currentHand; + + expect(actualState).toMatchObject(expectedState); + }) + }) +}); \ No newline at end of file diff --git a/test/demo/model.test.js b/test/demo/model.test.js deleted file mode 100644 index 49bf9599..00000000 --- a/test/demo/model.test.js +++ /dev/null @@ -1,338 +0,0 @@ -import Model from 'demo/model'; -import Adagrams from 'demo/adagrams'; - -describe.skip('Game Model', () => { - const config = { - players: [ - 'Player A', - 'Player B', - ], - rounds: 3, - time: 60, // Seconds - }; - - describe('constructor', () => { - it('creates a new Model instance', () => { - const model = new Model(config); - - expect(model).toBeInstanceOf(Model); - }); - - it('requires a config parameter', () => { - expect(() => { - const model = new Model(); - }).toThrow(/config/); - }); - - it('initializes the round number to zero', () => { - const model = new Model(config); - - expect(model.round).toBe(0); - }); - - it('initializes the current player to null', () => { - const model = new Model(config); - - expect(model.currentPlayer).toBe(null); - }); - - it('initializes the letter bank to null', () => { - const model = new Model(config); - - expect(model.letterBank).toBe(null); - }); - - it('initializes the plays history', () => { - const model = new Model(config); - - expect(model.plays).toBeInstanceOf(Object); - config.players.forEach((player) => { - expect(model.plays).toHaveProperty(player); - expect(model.plays[player]).toBeInstanceOf(Array); - expect(model.plays[player]).toHaveLength(0); - }); - }); - }); - - describe('.currentPlayerName()', () => { - it('is defined', () => { - const model = new Model(config); - - expect(model.currentPlayerName).toBeDefined(); - }); - - it('returns the name of the current player when game is on-going', () => { - const model = new Model(config); - - model.nextRound(); - - expect(model.currentPlayerName()).toEqual(model.config.players[0]); - }); - - it('returns null when the game is not on-going', () => { - const model = new Model(config); - - expect(model.currentPlayerName()).toBe(null); - }); - }); - - describe('.nextRound', () => { - it('is defined', () => { - const model = new Model(config); - - expect(model.nextRound).toBeDefined(); - }); - - it('increments the round number', () => { - const model = new Model(config); - const roundBefore = model.round; - - model.nextRound(); - - expect(model.round).toBe(roundBefore + 1); - }); - - it('initializes the current player number to first player', () => { - const model = new Model(config); - - model.nextRound(); - - expect(model.currentPlayer).toBe(0); - }); - - it('initializes the round play history for first player', () => { - const model = new Model(config); - - model.nextRound(); - - config.players.forEach((player) => { - const roundPlays = model.plays[player][model.round - 1]; - - expect(roundPlays).toBeInstanceOf(Array); - expect(roundPlays).toHaveLength(0); - }); - }); - - it('draws a new hand of letters', () => { - const model = new Model(config); - - model.nextRound(); - - expect(model.letterBank).toBeInstanceOf(Array); - expect(model.letterBank).toHaveLength(10); - model.letterBank.forEach((letter) => { - expect(letter).toMatch(/^[A-Z]$/); - }); - }); - - describe('returns game state', () => { - it('gameOver', () => { - const model = new Model({ ...config, rounds: 1 }); - - const gameState = model.nextRound(); - - expect(gameState).toBeInstanceOf(Object); - expect(gameState.gameOver).toBe(false); - - const gameOverState = model.nextRound(); - expect(gameOverState.gameOver).toBe(true); - }); - - it('winner', () => { - const model = new Model({ ...config, rounds : 2 }); - - // Start game, no one has won yet - let gameState = model.nextRound(); - expect(gameState.winner).toBe(null); - - // First player plays a word - let p1Score = 0; - let word = model.letterBank.slice(0, 5).join(''); - p1Score += model.playWord(word); - - // Second player does not play - model.nextTurn(); - gameState = model.nextRound(); - // Game is not over, so no winner yet - expect(gameState.winner).toBe(null); - - // First player plays another word - word = model.letterBank.slice(0, 5).join(''); - p1Score += model.playWord(word); - - // Second player does not play again - model.nextTurn(); - gameState = model.nextRound(); - - // Game is over now, first player has won - expect(gameState.winner).toMatchObject({ - player: config.players[0], - score: p1Score, - }); - }); - }); - }); - - describe('.nextTurn', () => { - const getModel = () => { - const model = new Model(config); - model.nextRound(); - - return model; - }; - - it('is defined', () => { - const model = getModel(); - - expect(model.nextTurn).toBeDefined(); - }); - - it('increments the current player index', () => { - const model = getModel(); - const origPlayer = model.currentPlayer; - - model.nextTurn(); - expect(model.currentPlayer).toBe(origPlayer + 1); - - model.nextTurn(); - expect(model.currentPlayer).toBe(origPlayer + 2); - }); - - describe('returns round state', () => { - it('roundOver', () => { - const model = getModel(); - - const roundState = model.nextTurn(); - - // Expect that we have at least two players, or round is over immediately - expect(config.players.length).toBeGreaterThan(1); - - expect(roundState).toBeInstanceOf(Object); - expect(roundState).toHaveProperty('roundOver'); - expect(roundState.roundOver).toBe(false); - - // Advance to the final turn - config.players.slice(2).forEach(() => { - model.nextTurn(); - }); - - // Complete the final turn, round should be over - const roundOverState = model.nextTurn(); - expect(roundOverState.roundOver).toBe(true); - }); - - it('winner', () => { - const model = getModel(); - - const roundState = model.nextTurn(); - - // winner should be null if round is not over - expect(roundState.roundOver).toBe(false); - expect(roundState.winner).toBe(null); - - // Advance to the final turn - config.players.slice(2).forEach(() => { - model.nextTurn(); - }); - - // Play a word as the last player - const word = model.letterBank.slice(0, 5).join(''); - const score = model.playWord(word); - - // Complete the final turn, round is over and winner should be set - const roundOverState = model.nextTurn(); - expect(roundOverState.roundOver).toBe(true); - - expect(roundOverState.winner).toBeInstanceOf(Object); - expect(roundOverState.winner).toMatchObject({ - word, - score, - player: config.players[config.players.length - 1], - }); - }); - }); - }); - - describe('.playWord', () => { - const getModel = () => { - const model = new Model(config); - model.nextRound(); - - return model; - }; - - const getPlays = (model, player, round) => { - return [...(model.plays[player][round - 1] || [])]; - }; - - it('is defined', () => { - const model = getModel(); - - expect(model.playWord).toBeDefined(); - }); - - describe('for valid words', () => { - const getWord = (model) => { - return model.letterBank.slice(0, 5).join(''); - }; - - it('it returns the word score', () => { - const model = getModel(); - const word = getWord(model); - const score = Adagrams.scoreWord(word); - - expect(model.playWord(word)).toBe(score); - }); - - it('adds word to plays history for current player', () => { - const model = getModel(); - const player = model.currentPlayerName(); - const origPlays = getPlays(model, player, model.round); - - const word1 = getWord(model); - model.playWord(word1); - expect(getPlays(model, player, model.round)).toEqual([...origPlays, word1]); - - const word2 = getWord(model); - model.playWord(word2); - expect(getPlays(model, player, model.round)).toEqual([...origPlays, word1, word2]); - }); - - it('validates word case-insensitively', () => { - const model = getModel(); - const word = getWord(model); - const score = Adagrams.scoreWord(word); - - expect(model.playWord(word.toLowerCase())).toBe(score); - }); - }); - - describe('for invalid words', () => { - const getWord = (model) => { - const letter = model.letterBank[0]; - return letter.repeat(model.letterBank.filter((l) => { - return l === letter; - }).length + 1); - }; - - it('it returns null', () => { - const model = getModel(); - const word = getWord(model); - - expect(model.playWord(word)).toBe(null); - expect(model.playWord('123')).toBe(null); - expect(model.playWord('')).toBe(null); - }); - - it('does not add word to history', () => { - const model = getModel(); - const word = getWord(model); - const origPlays = {...model.plays}; - - model.playWord(word); - - expect(model.plays).toEqual(origPlays); - }); - }); - }); -}); diff --git a/yarn.lock b/yarn.lock index 01caa24c..7257b970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -484,6 +484,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-jsx@^7.17.12": + version "7.17.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.17.12.tgz#834035b45061983a491f60096f61a2e7c5674a47" + integrity sha512-spyY3E3AURfxh/RHtjx5j6hs8am5NbUBGfcZ2vB3uShSpZdQyXSf5rR5Mk76vbtlAZOelyVQ71Fg0x9SG4fsog== + dependencies: + "@babel/helper-plugin-utils" "^7.17.12" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -726,6 +733,39 @@ dependencies: "@babel/helper-plugin-utils" "^7.16.7" +"@babel/plugin-transform-react-display-name@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.16.7.tgz#7b6d40d232f4c0f550ea348593db3b21e2404340" + integrity sha512-qgIg8BcZgd0G/Cz916D5+9kqX0c7nPZyXaP8R2tLNN5tkyIZdG5fEwBrxwplzSnjC1jvQmyMNVwUCZPcbGY7Pg== + dependencies: + "@babel/helper-plugin-utils" "^7.16.7" + +"@babel/plugin-transform-react-jsx-development@^7.16.7": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz#43a00724a3ed2557ed3f276a01a929e6686ac7b8" + integrity sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A== + dependencies: + "@babel/plugin-transform-react-jsx" "^7.16.7" + +"@babel/plugin-transform-react-jsx@^7.16.7", "@babel/plugin-transform-react-jsx@^7.17.12": + version "7.17.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.12.tgz#2aa20022709cd6a3f40b45d60603d5f269586dba" + integrity sha512-Lcaw8bxd1DKht3thfD4A12dqo1X16he1Lm8rIv8sTwjAYNInRS1qHa9aJoqvzpscItXvftKDCfaEQzwoVyXpEQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.16.7" + "@babel/helper-module-imports" "^7.16.7" + "@babel/helper-plugin-utils" "^7.17.12" + "@babel/plugin-syntax-jsx" "^7.17.12" + "@babel/types" "^7.17.12" + +"@babel/plugin-transform-react-pure-annotations@^7.16.7": + version "7.18.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.0.tgz#ef82c8e310913f3522462c9ac967d395092f1954" + integrity sha512-6+0IK6ouvqDn9bmEG7mEyF/pwlJXVj5lwydybpyyH3D0A7Hftk+NCTdYjnLNZksn261xaOV5ksmp20pQEmc2RQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.16.7" + "@babel/helper-plugin-utils" "^7.17.12" + "@babel/plugin-transform-regenerator@^7.18.0": version "7.18.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.0.tgz#44274d655eb3f1af3f3a574ba819d3f48caf99d5" @@ -884,6 +924,18 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" +"@babel/preset-react@^7.17.12": + version "7.17.12" + resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.17.12.tgz#62adbd2d1870c0de3893095757ed5b00b492ab3d" + integrity sha512-h5U+rwreXtZaRBEQhW1hOJLMq8XNJBQ/9oymXiCXTuT/0uOwpbT0gUt+sXeOqoXBgNuUKI7TaObVwoEyWkpFgA== + dependencies: + "@babel/helper-plugin-utils" "^7.17.12" + "@babel/helper-validator-option" "^7.16.7" + "@babel/plugin-transform-react-display-name" "^7.16.7" + "@babel/plugin-transform-react-jsx" "^7.17.12" + "@babel/plugin-transform-react-jsx-development" "^7.16.7" + "@babel/plugin-transform-react-pure-annotations" "^7.16.7" + "@babel/register@^7.17.7": version "7.17.7" resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.17.7.tgz#5eef3e0f4afc07e25e847720e7b987ae33f08d0b" @@ -935,6 +987,14 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@babel/types@^7.17.12": + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.4.tgz#27eae9b9fd18e9dccc3f9d6ad051336f307be354" + integrity sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw== + dependencies: + "@babel/helper-validator-identifier" "^7.16.7" + to-fast-properties "^2.0.0" + "@cnakazawa/watch@^1.0.3": version "1.0.4" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" @@ -1206,6 +1266,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/yoga-layout@1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@types/yoga-layout/-/yoga-layout-1.9.2.tgz#efaf9e991a7390dc081a0b679185979a83a9639a" + integrity sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw== + abab@^2.0.0: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -1244,20 +1309,17 @@ ajv@^6.12.3: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-escapes@^1.0.0, ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - integrity sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw== - ansi-escapes@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" ansi-regex@^3.0.0: version "3.0.1" @@ -1269,10 +1331,10 @@ ansi-regex@^4.0.0, ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed" integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g== -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA== +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" @@ -1281,6 +1343,13 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -1355,6 +1424,11 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" @@ -1370,6 +1444,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +auto-bind@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb" + integrity sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ== + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -1457,15 +1536,6 @@ babel-plugin-polyfill-regenerator@^0.3.0: dependencies: "@babel/helper-define-polyfill-provider" "^0.3.1" -babel-polyfill@^6.3.14: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153" - integrity sha512-F2rZGQnAdaHWQ8YAoeRbukc7HS9QgdgeyJ0rQDd485v9opwuPvjpPFcOOT/WmkKTdgy9ESgSPXDcTNpzrGr6iQ== - dependencies: - babel-runtime "^6.26.0" - core-js "^2.5.0" - regenerator-runtime "^0.10.5" - babel-preset-jest@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-24.9.0.tgz#192b521e2217fb1d1f67cf73f70c336650ad3cdc" @@ -1474,14 +1544,6 @@ babel-preset-jest@^24.9.0: "@babel/plugin-syntax-object-rest-spread" "^7.0.0" babel-plugin-jest-hoist "^24.9.0" -babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g== - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -1635,17 +1697,6 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== -chalk@^1.0.0, chalk@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1655,6 +1706,14 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chokidar@^3.4.0: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -1685,17 +1744,25 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -cli-cursor@^1.0.1, cli-cursor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - integrity sha512-25tABq090YNKkF6JH7lcwO0zFJTRke4Jcq9iX2nr/Sz0Cjjv4gckmwlW6Ty/aoyFd6z3ysR2hMGC2GFugmBo6A== +cli-boxes@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== dependencies: - restore-cursor "^1.0.1" + restore-cursor "^3.1.0" -cli-width@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-1.1.1.tgz#a4d293ef67ebb7b88d4a4d42c0ccf00c4d1e366d" - integrity sha512-eMU2akIeEIkCxGXUNmDnJq1KzOIiPnJ+rKqRe6hcxE3vIOPvpMrBYOn/Bl7zNlYJj/zQxXquAnozHUCf9Whnsg== +cli-truncate@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" + integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + dependencies: + slice-ansi "^3.0.0" + string-width "^4.2.0" cliui@^5.0.0: version "5.0.0" @@ -1720,10 +1787,12 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA== +code-excerpt@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-3.0.0.tgz#fcfb6748c03dba8431c19f5474747fad3f250f10" + integrity sha512-VHNTVhd7KsLGOqfX3SyeO8RyYPMp1GJOg194VITk04WMYCv4plV68YWe6TJZxd9MhobjtpMRnVky01gqZsalaw== + dependencies: + convert-to-spaces "^1.0.1" collection-visit@^1.0.0: version "1.0.0" @@ -1740,11 +1809,23 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -1779,6 +1860,11 @@ convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +convert-to-spaces@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/convert-to-spaces/-/convert-to-spaces-1.0.2.tgz#7e3e48bbe6d997b1417ddca2868204b4d3d85715" + integrity sha512-cj09EBuObp9gZNQCzc7hByQyrs6jVGE+o9kSJmeUoj+GiPiJvi5LYqEH/Hmme4+MTLHM+Ejtq+FChpjjEnsPdQ== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -1792,11 +1878,6 @@ core-js-compat@^3.21.0, core-js-compat@^3.22.1: browserslist "^4.20.3" semver "7.0.0" -core-js@^2.4.0, core-js@^2.5.0: - version "2.6.12" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" - integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== - core-js@^3.22.1, core-js@^3.8.0: version "3.22.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.7.tgz#8d6c37f630f6139b8732d10f2c114c3f1d00024f" @@ -1805,7 +1886,7 @@ core-js@^3.22.1, core-js@^3.8.0: core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== cross-spawn@^6.0.0: version "6.0.5" @@ -1833,7 +1914,7 @@ cssstyle@^1.0.0: dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== dependencies: assert-plus "^1.0.0" @@ -1863,7 +1944,7 @@ debug@^4.1.0, debug@^4.1.1: decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== decode-uri-component@^0.2.0: version "0.2.0" @@ -1908,12 +1989,12 @@ define-property@^2.0.2: delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" - integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + integrity sha512-CwffZFvlJffUg9zZA0uqrjQayUTC8ob94pnr5sFwaVv3IOmkfUHcWH+jXaQK3askE51Cqe8/9Ql/0uXNwqZ8Zg== diff-sequences@^24.9.0: version "24.9.0" @@ -1930,7 +2011,7 @@ domexception@^1.0.1: ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== dependencies: jsbn "~0.1.0" safer-buffer "^2.1.0" @@ -1945,6 +2026,11 @@ emoji-regex@^7.0.1: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -2007,7 +2093,7 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: +escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= @@ -2062,15 +2148,10 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g= - exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== expand-brackets@^2.1.4: version "2.1.4" @@ -2134,7 +2215,7 @@ extglob@^2.0.4: extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== extsprintf@^1.2.0: version "1.4.1" @@ -2154,7 +2235,7 @@ fast-json-stable-stringify@^2.0.0: fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fb-watchman@^2.0.0: version "2.0.1" @@ -2163,14 +2244,6 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4= - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -2239,7 +2312,7 @@ for-in@^1.0.2: forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== form-data@~2.3.2: version "2.3.3" @@ -2342,7 +2415,7 @@ get-value@^2.0.3, get-value@^2.0.6: getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== dependencies: assert-plus "^1.0.0" @@ -2378,12 +2451,12 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" - integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + integrity sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw== har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== har-validator@~5.1.3: version "5.1.5" @@ -2393,13 +2466,6 @@ har-validator@~5.1.3: ajv "^6.12.3" har-schema "^2.0.0" -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= - dependencies: - ansi-regex "^2.0.0" - has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -2410,6 +2476,11 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-property-descriptors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" @@ -2494,7 +2565,7 @@ html-escaper@^2.0.0: http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== dependencies: assert-plus "^1.0.0" jsprim "^1.2.2" @@ -2520,10 +2591,10 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -in-publish@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.1.tgz#948b1a535c8030561cea522f73f78f4be357e00c" - integrity sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ== +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== inflight@^1.0.4: version "1.0.6" @@ -2538,23 +2609,47 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -inquirer@0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.11.0.tgz#7448bfa924092af311d47173bbab990cae2bb027" - integrity sha1-dEi/qSQJKvMR1HFzu6uZDK4rsCc= - dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^1.0.1" - figures "^1.3.5" - lodash "^3.3.1" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - strip-ansi "^3.0.0" - through "^2.3.6" +ink-testing-library@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ink-testing-library/-/ink-testing-library-2.1.0.tgz#b5ffd1ef1049550ae4d2f008b8770e7ece6e0313" + integrity sha512-7TNlOjJlJXB33vG7yVa+MMO7hCjaC1bCn+zdpSjknWoLbOWMaFdKc7LJvqVkZ0rZv2+akhjXPrcR/dbxissjUw== + +ink-text-input@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/ink-text-input/-/ink-text-input-4.0.3.tgz#6348fef942e74b06a465f98851706516a1e2be8d" + integrity sha512-eQD01ik9ltmNoHmkeQ2t8LszYkv2XwuPSUz3ie/85qer6Ll/j0QSlSaLNl6ENHZakBHdCBVZY04iOXcLLXA0PQ== + dependencies: + chalk "^4.1.0" + type-fest "^0.15.1" + +ink@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ink/-/ink-3.2.0.tgz#434793630dc57d611c8fe8fffa1db6b56f1a16bb" + integrity sha512-firNp1q3xxTzoItj/eOOSZQnYSlyrWks5llCTVX37nJ59K3eXbQ8PtzCguqo8YI19EELo5QxaKnJd4VxzhU8tg== + dependencies: + ansi-escapes "^4.2.1" + auto-bind "4.0.0" + chalk "^4.1.0" + cli-boxes "^2.2.0" + cli-cursor "^3.1.0" + cli-truncate "^2.1.0" + code-excerpt "^3.0.0" + indent-string "^4.0.0" + is-ci "^2.0.0" + lodash "^4.17.20" + patch-console "^1.0.0" + react-devtools-core "^4.19.1" + react-reconciler "^0.26.2" + scheduler "^0.20.2" + signal-exit "^3.0.2" + slice-ansi "^3.0.0" + stack-utils "^2.0.2" + string-width "^4.2.2" + type-fest "^0.12.0" + widest-line "^3.1.0" + wrap-ansi "^6.2.0" + ws "^7.5.5" + yoga-layout-prebuilt "^1.9.6" internal-slot@^1.0.3: version "1.0.3" @@ -2693,17 +2788,15 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== is-generator-fn@^2.0.0: version "2.1.0" @@ -2785,7 +2878,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== is-weakref@^1.0.2: version "1.0.2" @@ -2802,7 +2895,7 @@ is-windows@^1.0.2: is-wsl@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + integrity sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw== isarray@1.0.0: version "1.0.0" @@ -2829,7 +2922,7 @@ isobject@^3.0.0, isobject@^3.0.1: isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== istanbul-lib-coverage@^2.0.2, istanbul-lib-coverage@^2.0.5: version "2.0.5" @@ -3238,7 +3331,7 @@ jest@^24.8.0: jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== jsdom@^11.5.1: version "11.12.0" @@ -3300,7 +3393,7 @@ json-schema@0.4.0: json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== json5@^0.5.1: version "0.5.1" @@ -3364,7 +3457,7 @@ leven@^3.1.0: levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA== dependencies: prelude-ls "~1.1.2" type-check "~0.3.2" @@ -3403,27 +3496,14 @@ lodash.debounce@^4.0.8: lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" - integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= - -lodash@^3.3.1: - version "3.10.1" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" - integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y= + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== -lodash@^4.17.19, lodash@^4.5.1: +lodash@^4.17.19, lodash@^4.17.20: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -log-update@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" - integrity sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE= - dependencies: - ansi-escapes "^1.0.0" - cli-cursor "^1.0.2" - -loose-envify@^1.0.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -3493,6 +3573,11 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.52.0" +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -3530,11 +3615,6 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA= - nan@^2.12.1: version "2.16.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916" @@ -3560,7 +3640,7 @@ nanomatch@^1.2.9: natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== nice-try@^1.0.4: version "1.0.5" @@ -3580,11 +3660,6 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= -node-localstorage@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/node-localstorage/-/node-localstorage-0.6.0.tgz#45a0601c6932dfde6644a23361f1be173c75d3af" - integrity sha1-RaBgHGky395mRKIzYfG+Fzx1068= - node-notifier@^5.4.2: version "5.4.5" resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.4.5.tgz#0cbc1a2b0f658493b4025775a13ad938e96091ef" @@ -3630,11 +3705,6 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - nwsapi@^2.0.7: version "2.2.0" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" @@ -3645,10 +3715,10 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== -object-assign@^4.1.0: +object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== object-copy@^0.1.0: version "0.1.0" @@ -3710,10 +3780,12 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" optionator@^0.8.1: version "0.8.3" @@ -3730,7 +3802,7 @@ optionator@^0.8.1: p-each-series@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-1.0.0.tgz#930f3d12dd1f50e7434457a22cd6f04ac6ad7f71" - integrity sha1-kw89Et0fUOdDRFeiLNbwSsatf3E= + integrity sha512-J/e9xiZZQNrt+958FFzJ+auItsBGq+UrQ7nE89AUP7UOTtjHnkISANXLdayhVzh538UnLMCSlf13lFfRIAKQOA== dependencies: p-reduce "^1.0.0" @@ -3770,7 +3842,7 @@ p-locate@^3.0.0: p-reduce@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa" - integrity sha1-GMKw3ZNqRpClKfgjH1ig/bakffo= + integrity sha512-3Tx1T3oM1xO/Y8Gj0sWyE78EIJZ+t+aEmXUdvQgvGmSMri7aPTHoovbXEreWKkL5j21Er60XAWLTzKbAKYOujQ== p-try@^1.0.0: version "1.0.0" @@ -3805,6 +3877,11 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= +patch-console@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-1.0.0.tgz#19b9f028713feb8a3c023702a8cc8cb9f7466f9d" + integrity sha512-nxl9nrnLQmh64iTzMfyylSlRozL7kAXIaxw1fVcLYdyhNkJCRUzirRZTikXGJsg+hc4fqpneTK6iU2H1Q8THSA== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -3835,7 +3912,7 @@ path-type@^3.0.0: performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== picocolors@^1.0.0: version "1.0.0" @@ -3889,7 +3966,7 @@ posix-character-classes@^0.1.0: prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== pretty-format@^24.9.0: version "24.9.0" @@ -3909,6 +3986,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + psl@^1.1.28: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" @@ -3932,11 +4018,36 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== -react-is@^16.8.4: +react-devtools-core@^4.19.1: + version "4.24.7" + resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.24.7.tgz#43df22e6d244ed8286fd3ff16a80813998fe82a0" + integrity sha512-OFB1cp8bsh5Kc6oOJ3ZzH++zMBtydwD53yBYa50FKEGyOOdgdbJ4VsCsZhN/6F5T4gJfrZraU6EKda8P+tMLtg== + dependencies: + shell-quote "^1.6.1" + ws "^7" + +react-is@^16.13.1, react-is@^16.8.4: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-reconciler@^0.26.2: + version "0.26.2" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.26.2.tgz#bbad0e2d1309423f76cf3c3309ac6c96e05e9d91" + integrity sha512-nK6kgY28HwrMNwDnMui3dvm3rCFjZrcGiuwLc5COUipBK5hWHLOxMJhSnSomirqWwjPBJKV1QcbkI0VJr7Gl1Q== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + +react@17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + read-pkg-up@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978" @@ -3961,15 +4072,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - integrity sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" - realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -3989,16 +4091,6 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@^0.10.5: - version "0.10.5" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" - integrity sha1-M2w+/BIgrc7dosn6tntaeVWjNlg= - -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - regenerator-runtime@^0.12.1: version "0.12.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" @@ -4160,13 +4252,13 @@ resolve@^1.10.0, resolve@^1.14.2, resolve@^1.4.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE= +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" ret@~0.1.10: version "0.1.15" @@ -4185,18 +4277,6 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - integrity sha1-yK1KXhEGYeQCp9IbUw4AnyX444k= - dependencies: - once "^1.3.0" - -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - integrity sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI= - safe-buffer@^5.0.1, safe-buffer@^5.1.2: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -4239,6 +4319,14 @@ sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + "semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" @@ -4288,6 +4376,11 @@ shebang-regex@^1.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= +shell-quote@^1.6.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" + integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== + shellwords@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" @@ -4317,6 +4410,15 @@ slash@^2.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== +slice-ansi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" + integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -4436,6 +4538,13 @@ stack-utils@^1.0.1: dependencies: escape-string-regexp "^2.0.0" +stack-utils@^2.0.2: + version "2.0.5" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.5.tgz#d25265fca995154659dbbfba3b49254778d2fdd5" + integrity sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA== + dependencies: + escape-string-regexp "^2.0.0" + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -4457,15 +4566,6 @@ string-length@^2.0.0: astral-regex "^1.0.0" strip-ansi "^4.0.0" -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - string-width@^3.0.0, string-width@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" @@ -4475,6 +4575,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string.prototype.trimend@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" @@ -4493,13 +4602,6 @@ string.prototype.trimstart@^1.0.5: define-properties "^1.1.4" es-abstract "^1.19.5" -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= - dependencies: - ansi-regex "^2.0.0" - strip-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" @@ -4514,6 +4616,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -4524,11 +4633,6 @@ strip-eof@^1.0.0: resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= - supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -4543,6 +4647,13 @@ supports-color@^6.1.0: dependencies: has-flag "^3.0.0" +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -4568,11 +4679,6 @@ throat@^4.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= -through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -4649,6 +4755,21 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-fest@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee" + integrity sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg== + +type-fest@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.15.1.tgz#d2c4e73d3e4a53cf1a906396dd460a1c5178ca00" + integrity sha512-n+UXrN8i5ioo7kqT/nF8xsEzLaqFra7k32SEsSPwvXVGyAcRgV/FUQN/sgfptJTR1oRmmq7z4IXMFSM7im7C9A== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -4757,22 +4878,6 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vorpal@^1.12.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/vorpal/-/vorpal-1.12.0.tgz#4be7b2a4e48f8fcfc9cf3648c419d311c522159d" - integrity sha1-S+eypOSPj8/JzzZIxBnTEcUiFZ0= - dependencies: - babel-polyfill "^6.3.14" - chalk "^1.1.0" - in-publish "^2.0.0" - inquirer "0.11.0" - lodash "^4.5.1" - log-update "^1.0.2" - minimist "^1.2.0" - node-localstorage "^0.6.0" - strip-ansi "^3.0.0" - wrap-ansi "^2.0.0" - w3c-hr-time@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -4845,19 +4950,18 @@ which@^1.2.9, which@^1.3.0: dependencies: isexe "^2.0.0" +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" @@ -4867,6 +4971,15 @@ wrap-ansi@^5.1.0: string-width "^3.0.0" strip-ansi "^5.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -4888,6 +5001,11 @@ ws@^5.2.0: dependencies: async-limiter "~1.0.0" +ws@^7, ws@^7.5.5: + version "7.5.8" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.8.tgz#ac2729881ab9e7cbaf8787fe3469a48c5c7f636a" + integrity sha512-ri1Id1WinAX5Jqn9HejiGb8crfRio0Qgu8+MtL36rlTA6RLsMdWt1Az/19A2Qij6uSHUMphEFaTKa4WG+UNHNw== + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" @@ -4921,3 +5039,10 @@ yargs@^13.3.0: which-module "^2.0.0" y18n "^4.0.0" yargs-parser "^13.1.2" + +yoga-layout-prebuilt@^1.9.6: + version "1.10.0" + resolved "https://registry.yarnpkg.com/yoga-layout-prebuilt/-/yoga-layout-prebuilt-1.10.0.tgz#2936fbaf4b3628ee0b3e3b1df44936d6c146faa6" + integrity sha512-YnOmtSbv4MTf7RGJMK0FvZ+KD8OEe/J5BNnR0GHhD8J/XcG/Qvxgszm0Un6FTHWW4uHlTgP0IztiXQnGyIR45g== + dependencies: + "@types/yoga-layout" "1.9.2"