16 Commits
14 changed files with 2026 additions and 722 deletions
+1381 -716
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -3,21 +3,34 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"lodash.shuffle": "^4.2.0",
"prop-types": "^15.6.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-scripts": "1.0.14"
"react-scripts": "^1.0.17"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"devDependencies": {
"chai": "^4.1.2",
"chai-enzyme": "^1.0.0-beta.0",
"chai-jest-diff": "^1.0.1",
"chai-jest-snapshot": "^2.0.0",
"dirty-chai": "^2.0.1",
"enzyme": "^3.2.0",
"enzyme-adapter-react-16": "^1.1.0",
"enzyme-to-json": "^3.2.2",
"eslint-config-prettier": "^2.6.0",
"eslint-config-react-app": "^2.0.1",
"eslint-plugin-prettier": "^2.3.1",
"prettier": "^1.7.4"
"prettier": "^1.7.4",
"react-test-renderer": "^16.2.0",
"sinon": "^4.1.3",
"sinon-chai": "^2.14.0"
},
"prettier": {
"semi": false,
+104 -1
View File
@@ -1,9 +1,112 @@
import React, { Component } from 'react'
import shuffle from 'lodash.shuffle'
import './App.css'
import Card from './Card'
import GuessCount from './GuessCount'
import HallOfFame, { FAKE_HOF } from './HallOfFame'
import HighScoreInput from './HighScoreInput'
const SIDE = 6
export const SYMBOLS = '😀🎉💖🎩🐶🐱🦄🐬🌍🌛🌞💫🍎🍌🍓🍐🍟🍿'
const VISUAL_PAUSE_MSECS = 750
class App extends Component {
state = {
cards: this.generateCards(),
currentPair: [],
guesses: 0,
hallOfFame: null,
matchedCardIndices: [],
}
// Arrow fx for binding
displayHallOfFame = hallOfFame => {
this.setState({ hallOfFame })
}
generateCards() {
const result = []
const size = SIDE * SIDE
const candidates = shuffle(SYMBOLS)
while (result.length < size) {
const card = candidates.pop()
result.push(card, card)
}
return shuffle(result)
}
getFeedbackForCard(index) {
const { currentPair, matchedCardIndices } = this.state
const indexMatched = matchedCardIndices.includes(index)
if (currentPair.length < 2) {
return indexMatched || index === currentPair[0] ? 'visible' : 'hidden'
}
if (currentPair.includes(index)) {
return indexMatched ? 'justMatched' : 'justMismatched'
}
return indexMatched ? 'visible' : 'hidden'
}
// Arrow fx for binding
handleCardClick = index => {
const { currentPair } = this.state
if (currentPair.length === 2) {
return
}
if (currentPair.length === 0) {
this.setState({ currentPair: [index] })
return
}
this.handleNewPairClosedBy(index)
}
handleNewPairClosedBy(index) {
const { cards, currentPair, guesses, matchedCardIndices } = this.state
const newPair = [currentPair[0], index]
const newGuesses = guesses + 1
const matched = cards[newPair[0]] === cards[newPair[1]]
this.setState({ currentPair: newPair, guesses: newGuesses })
if (matched) {
this.setState({ matchedCardIndices: [...matchedCardIndices, ...newPair] })
}
setTimeout(() => this.setState({ currentPair: [] }), VISUAL_PAUSE_MSECS)
}
render() {
return <div className="memory" />
const { cards, guesses, hallOfFame, matchedCardIndices } = this.state
const won = matchedCardIndices.length === cards.length
return (
<div className="memory">
<GuessCount guesses={guesses} />
{cards.map((card, index) => (
<Card
card={card}
feedback={this.getFeedbackForCard(index)}
index={index}
key={index}
onClick={this.handleCardClick}
/>
))}
{won &&
(hallOfFame ? (
<HallOfFame entries={hallOfFame} />
) : (
<HighScoreInput
guesses={guesses}
onStored={this.displayHallOfFame}
/>
))}
</div>
)
}
}
+37
View File
@@ -0,0 +1,37 @@
import { expect } from 'chai'
import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import App, { SYMBOLS } from './App'
import GuessCount from './GuessCount'
describe('<App/>', () => {
it('renders without crashing', () => {
const wrapper = shallow(<App />)
})
it('contains a zero-guess counter', () => {
const wrapper = shallow(<App />)
expect(wrapper).to.contain(<GuessCount guesses={0} />)
})
it('has 36 cards', () => {
const wrapper = shallow(<App />)
expect(wrapper.find('Card')).to.have.length(36)
})
it('should match its reference snapshot', () => {
const mock = sinon
.stub(App.prototype, 'generateCards')
.returns([...SYMBOLS.repeat(2)])
try {
const wrapper = shallow(<App />)
expect(wrapper).to.matchSnapshot()
} finally {
mock.restore()
}
})
})
+20 -1
View File
@@ -1,9 +1,28 @@
import PropTypes from 'prop-types'
import React from 'react'
import './Card.css'
const HIDDEN_SYMBOL = '❓'
const Card = () => <div className="card" />
const Card = ({ card, feedback, index, onClick }) => (
<div className={`card ${feedback}`} onClick={() => onClick(index)}>
<span className="symbol">
{feedback === 'hidden' ? HIDDEN_SYMBOL : card}
</span>
</div>
)
Card.propTypes = {
card: PropTypes.string.isRequired,
feedback: PropTypes.oneOf([
'hidden',
'justMatched',
'justMismatched',
'visible',
]).isRequired,
index: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
}
export default Card
+27
View File
@@ -0,0 +1,27 @@
import { expect } from 'chai'
import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import Card from './Card'
describe('<Card/>', () => {
it('should trigger its `onClick` prop when clicked', () => {
const onClick = sinon.spy()
const wrapper = shallow(
<Card card="😁" feedback="hidden" index={0} onClick={onClick} />
)
wrapper.simulate('click')
expect(onClick).to.have.been.calledWith(0)
})
it('should match its reference snapshot', () => {
const onClick = sinon.spy()
const wrapper = shallow(
<Card card="😁" feedback="hidden" index={0} onClick={onClick} />
)
expect(wrapper).to.matchSnapshot()
})
})
+6 -1
View File
@@ -1,7 +1,12 @@
import PropTypes from 'prop-types'
import React from 'react'
import './GuessCount.css'
const GuessCount = () => <div className="guesses" />
const GuessCount = ({ guesses }) => <div className="guesses">{guesses}</div>
GuessCount.propTypes = {
guesses: PropTypes.number.isRequired,
}
export default GuessCount
+14
View File
@@ -0,0 +1,14 @@
.hallOfFame {
width: 100%;
border-collapse: collapse;
}
.hallOfFame .date,
.hallOfFame .guesses {
width: 20%;
text-align: center;
}
.hallOfFame tr:nth-child(even) {
background: #eee;
}
+65
View File
@@ -0,0 +1,65 @@
import PropTypes from 'prop-types'
import React from 'react'
import './HallOfFame.css'
const HallOfFame = ({ entries }) => (
<table className="hallOfFame">
<tbody>
{entries.map(({ date, guesses, id, player }) => (
<tr key={id}>
<td className="date">{date}</td>
<td className="guesses">{guesses}</td>
<td className="player">{player}</td>
</tr>
))}
</tbody>
</table>
)
HallOfFame.propTypes = {
entries: PropTypes.arrayOf(
PropTypes.shape({
date: PropTypes.string.isRequired,
guesses: PropTypes.number.isRequired,
id: PropTypes.number.isRequired,
player: PropTypes.string.isRequired,
})
).isRequired,
}
export default HallOfFame
// == Internal helpers ==============================================
export const FAKE_HOF = [
{ id: 3, guesses: 18, date: '10/10/2017', player: 'Jane' },
{ id: 2, guesses: 23, date: '11/10/2017', player: 'Kevin' },
{ id: 1, guesses: 31, date: '06/10/2017', player: 'Louisa' },
{ id: 0, guesses: 48, date: '14/10/2017', player: 'Marc' },
]
const HOF_KEY = '::Memory::HallofFame'
const HOF_MAX_SIZE = 10
export function saveHOFEntry(entry, onStored) {
entry.date = new Date().toLocaleDateString()
entry.id = Date.now()
const entries = JSON.parse(localStorage.getItem(HOF_KEY) || '[]')
const insertionPoint = entries.findIndex(
({ guesses }) => guesses >= entry.guesses
)
if (insertionPoint === -1) {
entries.push(entry)
} else {
entries.splice(insertionPoint, 0, entry)
}
if (entries.length > HOF_MAX_SIZE) {
entries.splice(HOF_MAX_SIZE, entries.length)
}
localStorage.setItem(HOF_KEY, JSON.stringify(entries))
onStored(entries)
}
+11
View File
@@ -0,0 +1,11 @@
.highScoreInput {
font-size: 1em;
width: calc(100% - 0.4em);
margin: 0.2em;
}
.highScoreInput input {
display: block;
width: 100%;
margin-bottom: 0.2em;
font-family: Menlo, Monaco, Consolas, Inconsolata, 'Courier New', monospace;
}
+48
View File
@@ -0,0 +1,48 @@
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import './HighScoreInput.css'
import { saveHOFEntry } from './HallOfFame'
class HighScoreInput extends Component {
state = { winner: '' }
// Arrow fx for binding
handleWinnerUpdate = event => {
this.setState({ winner: event.target.value.toUpperCase() })
}
// Arrow fx for binding
persistWinner = event => {
event.preventDefault()
const newEntry = { guesses: this.props.guesses, player: this.state.winner }
saveHOFEntry(newEntry, this.props.onStored)
}
render() {
return (
<form className="highScoreInput" onSubmit={this.persistWinner}>
<p>
<label>
Bravo ! Entre ton prénom :
<input
autoComplete="given-name"
type="text"
onChange={this.handleWinnerUpdate}
value={this.state.winner}
/>
</label>
<button type="submit">J’ai gagné !</button>
</p>
</form>
)
}
}
HighScoreInput.propTypes = {
guesses: PropTypes.number.isRequired,
onStored: PropTypes.func.isRequired,
}
export default HighScoreInput
+263
View File
@@ -0,0 +1,263 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<App/> should match its reference snapshot 1`] = `
<div
className="memory"
>
<GuessCount
guesses={0}
/>
<Card
card="😀"
feedback="hidden"
index={0}
key="0"
onClick={[Function]}
/>
<Card
card="🎉"
feedback="hidden"
index={1}
key="1"
onClick={[Function]}
/>
<Card
card="💖"
feedback="hidden"
index={2}
key="2"
onClick={[Function]}
/>
<Card
card="🎩"
feedback="hidden"
index={3}
key="3"
onClick={[Function]}
/>
<Card
card="🐶"
feedback="hidden"
index={4}
key="4"
onClick={[Function]}
/>
<Card
card="🐱"
feedback="hidden"
index={5}
key="5"
onClick={[Function]}
/>
<Card
card="🦄"
feedback="hidden"
index={6}
key="6"
onClick={[Function]}
/>
<Card
card="🐬"
feedback="hidden"
index={7}
key="7"
onClick={[Function]}
/>
<Card
card="🌍"
feedback="hidden"
index={8}
key="8"
onClick={[Function]}
/>
<Card
card="🌛"
feedback="hidden"
index={9}
key="9"
onClick={[Function]}
/>
<Card
card="🌞"
feedback="hidden"
index={10}
key="10"
onClick={[Function]}
/>
<Card
card="💫"
feedback="hidden"
index={11}
key="11"
onClick={[Function]}
/>
<Card
card="🍎"
feedback="hidden"
index={12}
key="12"
onClick={[Function]}
/>
<Card
card="🍌"
feedback="hidden"
index={13}
key="13"
onClick={[Function]}
/>
<Card
card="🍓"
feedback="hidden"
index={14}
key="14"
onClick={[Function]}
/>
<Card
card="🍐"
feedback="hidden"
index={15}
key="15"
onClick={[Function]}
/>
<Card
card="🍟"
feedback="hidden"
index={16}
key="16"
onClick={[Function]}
/>
<Card
card="🍿"
feedback="hidden"
index={17}
key="17"
onClick={[Function]}
/>
<Card
card="😀"
feedback="hidden"
index={18}
key="18"
onClick={[Function]}
/>
<Card
card="🎉"
feedback="hidden"
index={19}
key="19"
onClick={[Function]}
/>
<Card
card="💖"
feedback="hidden"
index={20}
key="20"
onClick={[Function]}
/>
<Card
card="🎩"
feedback="hidden"
index={21}
key="21"
onClick={[Function]}
/>
<Card
card="🐶"
feedback="hidden"
index={22}
key="22"
onClick={[Function]}
/>
<Card
card="🐱"
feedback="hidden"
index={23}
key="23"
onClick={[Function]}
/>
<Card
card="🦄"
feedback="hidden"
index={24}
key="24"
onClick={[Function]}
/>
<Card
card="🐬"
feedback="hidden"
index={25}
key="25"
onClick={[Function]}
/>
<Card
card="🌍"
feedback="hidden"
index={26}
key="26"
onClick={[Function]}
/>
<Card
card="🌛"
feedback="hidden"
index={27}
key="27"
onClick={[Function]}
/>
<Card
card="🌞"
feedback="hidden"
index={28}
key="28"
onClick={[Function]}
/>
<Card
card="💫"
feedback="hidden"
index={29}
key="29"
onClick={[Function]}
/>
<Card
card="🍎"
feedback="hidden"
index={30}
key="30"
onClick={[Function]}
/>
<Card
card="🍌"
feedback="hidden"
index={31}
key="31"
onClick={[Function]}
/>
<Card
card="🍓"
feedback="hidden"
index={32}
key="32"
onClick={[Function]}
/>
<Card
card="🍐"
feedback="hidden"
index={33}
key="33"
onClick={[Function]}
/>
<Card
card="🍟"
feedback="hidden"
index={34}
key="34"
onClick={[Function]}
/>
<Card
card="🍿"
feedback="hidden"
index={35}
key="35"
onClick={[Function]}
/>
</div>
`;
+14
View File
@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Card/> should match its reference snapshot 1`] = `
<div
className="card hidden"
onClick={[Function]}
>
<span
className="symbol"
>
❓
</span>
</div>
`;
+20
View File
@@ -0,0 +1,20 @@
import Adapter from 'enzyme-adapter-react-16'
import chai from 'chai'
import { configure as configureEnzyme } from 'enzyme'
import chaiJestSnapshot from 'chai-jest-snapshot'
import createChaiEnzyme from 'chai-enzyme'
import createChaiJestDiff from 'chai-jest-diff'
import dirtyChai from 'dirty-chai'
import enzymeToJSON from 'enzyme-to-json/serializer'
import sinonChai from 'sinon-chai'
chai
.use(dirtyChai)
.use(createChaiJestDiff())
.use(chaiJestSnapshot)
.use(createChaiEnzyme())
.use(sinonChai)
expect.addSnapshotSerializer(enzymeToJSON)
configureEnzyme({ adapter: new Adapter() })