curriculum/challenges/english/blocks/lab-tic-tac-toe/67e3a6b7f60b4085588189e6.md
Objective: Fulfill the user stories below and get all the tests to pass to complete the lab.
User Stories:
Board component that renders nine button elements each with a class of square in a 3x3 grid.button.square elements should alternate between displaying an X then O within the element.button.square should not cause any further changes.button#reset element that resets the game when clicked.X or O as the winner, or neither if the result is a draw.let clock = __FakeTimers.install();
async function reset(assert) {
const reset = document.querySelector("#reset");
assert.exists(reset, "button#reset should exist");
reset.click();
clock.tick(50);
}
// Gets the text of the document excluding that of the matching selector.
function getInnerTextExcept(removingSelector) {
const body = document.body.cloneNode(true);
const squareElements = body.querySelectorAll(`${removingSelector},script`);
squareElements.forEach(element => {
element.parentNode.removeChild(element)
});
return body.innerText;
}
clock.uninstall();
You should export a Board component.
const script = [...document.querySelectorAll("script")].find((s) => s.dataset.src === "index.jsx").innerText;
const exports = {};
const a = eval(script);
assert.property(exports, "Board");
You should have nine button.square elements.
const els = document.querySelectorAll("button.square");
assert.equal(els.length, 9);
The button.square elements should be in a 3x3 grid.
// TODO: Maybe enforce buttons to be same size?
const els = document.querySelectorAll("button.square");
const squares = Array.from(els);
const coords = squares.map((square) => {
const rect = square.getBoundingClientRect();
return rect;
});
const xTolerance = coords[0].width / 10;
const yTolerance = coords[0].height / 10;
try {
assert.isBelow(coords[0].x, coords[1].x, "First square should be to the left of the second square");
assert.isBelow(coords[1].x, coords[2].x, "Second square should be to the left of the third square");
assert.approximately(coords[0].y, coords[1].y, yTolerance, "First square should be at the same height as the second square");
assert.approximately(coords[1].y, coords[2].y, yTolerance, "Second square should be at the same height as the third square");
assert.isBelow(coords[3].x, coords[4].x, "Fourth square should be to the left of the fifth square");
assert.isBelow(coords[4].x, coords[5].x, "Fifth square should be to the left of the sixth square");
assert.approximately(coords[3].y, coords[4].y, yTolerance, "Fourth square should be at the same height as the fifth square");
assert.approximately(coords[4].y, coords[5].y, yTolerance, "Fifth square should be at the same height as the sixth square");
assert.isBelow(coords[6].x, coords[7].x, "Seventh square should be to the left of the eighth square");
assert.isBelow(coords[7].x, coords[8].x, "Eighth square should be to the left of the ninth square");
assert.approximately(coords[6].y, coords[7].y, yTolerance, "Seventh square should be at the same height as the eighth square");
assert.approximately(coords[7].y, coords[8].y, yTolerance, "Eighth square should be at the same height as the ninth square");
assert.isBelow(coords[0].y, coords[3].y, "First square should be above the fourth square");
assert.isBelow(coords[3].y, coords[6].y, "Fourth square should be above the seventh square");
assert.approximately(coords[0].x, coords[3].x, xTolerance, "First square should be at the same width as the fourth square");
assert.approximately(coords[3].x, coords[6].x, xTolerance, "Fourth square should be at the same width as the seventh square");
assert.isBelow(coords[1].y, coords[4].y, "Second square should be above the fifth square");
assert.isBelow(coords[4].y, coords[7].y, "Fifth square should be above the eighth square");
assert.approximately(coords[1].x, coords[4].x, xTolerance, "Second square should be at the same width as the fifth square");
assert.approximately(coords[4].x, coords[7].x, xTolerance, "Fifth square should be at the same width as the eighth square");
assert.isBelow(coords[2].y, coords[5].y, "Third square should be above the sixth square");
assert.isBelow(coords[5].y, coords[8].y, "Sixth square should be above the ninth square");
assert.approximately(coords[2].x, coords[5].x, xTolerance, "Third square should be at the same width as the sixth square");
assert.approximately(coords[5].x, coords[8].x, xTolerance, "Sixth square should be at the same width as the ninth square");
} catch (e) {
console.error(e)
throw e;
}
The first click of a button.square element should result in X being displayed within the element.
const el = document.querySelector("button.square");
el.click();
clock.tick(50);
try {
assert.include(el.textContent, "X");
} catch(e) {
console.error(e);
throw e;
}
Clicking on the button#reset element should reset the game.
// NOTE: This test is intentionally high-up, because the latter tests rely on the functionality.
const el = document.querySelector("button.square");
el.click();
clock.tick(50);
try {
await reset(assert);
assert.notInclude(el.textContent, "X");
} catch(e) {
console.error(e);
throw e;
}
The second click of a button.square element should result in O being displayed within the element.
await reset(assert);
const els = document.querySelectorAll("button.square");
els[0].click();
clock.tick(50);
els[1].click();
clock.tick(50);
try {
assert.include(els[1].textContent, "O");
} catch(e) {
console.error(e);
throw e;
}
All subsequent clicks of a button.square element should alternate between displaying X and O within the element.
await reset(assert);
const els = document.querySelectorAll("button.square");
try {
for (let i = 3; i < els.length + 3; i++) {
const wrappedI = i % els.length;
els[wrappedI].click();
clock.tick(50);
assert.include(els[wrappedI].textContent, (i - 3) % 2 === 0 ? "X" : "O");
}
} catch(e) {
console.error(e);
throw e;
}
Clicking on an already used button.square element should result in no change.
await reset(assert);
const els = document.querySelectorAll("button.square");
try {
for (let i = 3; i < els.length + 3; i++) {
const wrappedI = i % els.length;
// Click button twice to ensure it does not change
els[wrappedI].click();
clock.tick(50);
els[wrappedI].click();
clock.tick(50);
assert.include(els[wrappedI].textContent, (i - 3) % 2 === 0 ? "X" : "O");
}
} catch(e) {
console.error(e);
throw e;
}
Clicking on a button.square element after the game has ended should result in no change.
await reset(assert);
const els = document.querySelectorAll("button.square");
try {
// Win game, then click empty square and ensure no change
for (let i = 0; i < 3; i++) {
const x = els[i];
x.click();
clock.tick(50);
assert.include(x.textContent, "X");
const o = els[i + 3];
o.click();
clock.tick(50);
if (i === 2) {
assert.notInclude(o.textContent, "O");
} else {
assert.include(o.textContent, "O");
}
}
} catch(e) {
console.error(e);
throw e;
}
The game should display a message indicating the winner to be X or O.
// Get to almost winning state
// Check dom
// Click winning square
// Check dom for change
await reset(assert);
const els = document.querySelectorAll("button.square");
try {
for (let i = 0; i < 2; i++) {
const x = els[i];
x.click();
clock.tick(50);
const o = els[i + 3];
o.click();
clock.tick(50);
}
const preXWin = getInnerTextExcept("button.square");
// Click winning button for X
els[2].click();
clock.tick(50);
const postXWin = getInnerTextExcept("button.square");
assert.notEqual(preXWin, postXWin);
await reset(assert);
for (let i = 0; i < 2; i++) {
const x = els[i];
x.click();
clock.tick(50);
const o = els[i + 3];
o.click();
clock.tick(50);
}
els[6].click();
clock.tick(50);
const preOWin = getInnerTextExcept("button.square");
// Click winning button for O
els[5].click();
clock.tick(50);
const postOWin = getInnerTextExcept("button.square");
assert.notEqual(preOWin, postOWin);
// There should be a difference between `O` and `X` winning.
assert.notEqual(postXWin, postOWin);
// Test diagonal win for X
await reset(assert);
// X moves: top-left
els[0].click();
clock.tick(50);
// O moves: top-center
els[1].click();
clock.tick(50);
// X moves: center
els[4].click();
clock.tick(50);
// O moves: top-right
els[2].click();
clock.tick(50);
const preDiagonalWin = getInnerTextExcept("button.square");
// X moves: bottom-right (completes diagonal win)
els[8].click();
clock.tick(50);
const postDiagonalWin = getInnerTextExcept("button.square");
assert.notEqual(preDiagonalWin, postDiagonalWin);
assert.include(postDiagonalWin, "Winner: X");
} catch(e) {
console.error(e);
throw e;
}
The game should display a message indicating a draw.
// Get to almost draw state
// Check dom
// Click final square
// Check dom for change
await reset(assert);
const els = document.querySelectorAll("button.square");
try {
//use already known draw states
const drawMoves = [0, 1, 2, 4, 3, 5, 7, 6, 8];
const snapshot1 = getInnerTextExcept("button.square");
els[drawMoves[0]].click();
clock.tick(50);
const snapshot2 = getInnerTextExcept("button.square");
for (let i = 1; i < drawMoves.length - 1; i++) {
els[drawMoves[i]].click();
clock.tick(50);
}
const snapshot3 = getInnerTextExcept("button.square");
els[drawMoves[drawMoves.length - 1]].click();
clock.tick(50);
const snapshot4 = getInnerTextExcept("button.square");
assert.notEqual(snapshot4, snapshot1);
assert.notEqual(snapshot4, snapshot2);
assert.notEqual(snapshot4, snapshot3);
} catch(e) {
console.error(e);
throw e;
}
Uninstall the clock, ignore this test
clock.uninstall()
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Tic-Tac-Toe</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
<script
data-plugins="transform-modules-umd"
type="text/babel"
src="index.jsx"
></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script
data-plugins="transform-modules-umd"
type="text/babel"
data-presets="react"
data-type="module"
>
import { Board } from './index.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(<Board />);
</script>
</body>
</html>
const { useState } = React;
export function Board() {
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Tic-Tac-Toe</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
<script
data-plugins="transform-modules-umd"
type="text/babel"
src="index.jsx"
></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script
data-plugins="transform-modules-umd"
type="text/babel"
data-presets="react"
data-type="module"
>
import { Board } from './index.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(<Board />);
</script>
</body>
</html>
* {
font-family: "Secular One", sans-serif;
font-weight: 400;
font-style: normal;
}
.board {
display: flex;
flex-direction: column;
align-items: center;
}
.status {
margin: 10px;
font-size: 1.2em;
}
.board-row {
display: flex;
}
.square {
width: 60px;
height: 60px;
font-size: 1.5em;
margin: 5px;
background: #fff;
border: 1px solid #999;
cursor: pointer;
border-radius: 5px;
}
.square:focus {
outline: none;
}
#reset {
margin-top: 20px;
padding: 10px 20px;
font-size: 1em;
cursor: pointer;
}
export function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null));
const [isXNext, setIsXNext] = React.useState(true);
const winner = calculateWinner(squares);
const isDraw = squares.every(square => square !== null) && !winner;
function handleClick(index) {
if (squares[index] || winner || isDraw) return;
const nextSquares = squares.slice();
nextSquares[index] = isXNext ? 'X' : 'O';
setSquares(nextSquares);
setIsXNext(!isXNext);
};
function resetGame() {
setSquares(Array(9).fill(null));
setIsXNext(true);
};
function renderSquare(index) {
return <Square value={squares[index]} onClick={() => handleClick(index)} />;
};
return (
<div className="board">
<h1>Tic-Tac-Toe</h1>
<div className="status">
{winner ? `Winner: ${winner}` : isDraw ? "It's a Draw!" : `Next Player: ${isXNext ? 'X' : 'O'}`}
</div>
<div className="board-row">
{renderSquare(0)}
{renderSquare(1)}
{renderSquare(2)}
</div>
<div className="board-row">
{renderSquare(3)}
{renderSquare(4)}
{renderSquare(5)}
</div>
<div className="board-row">
{renderSquare(6)}
{renderSquare(7)}
{renderSquare(8)}
</div>
<button id="reset" onClick={resetGame}>Reset Game</button>
</div>
);
};
function Square({ value, onClick }) {
return (
<button className="square" onClick={onClick}>
{value}
</button>
);
};
function calculateWinner(squares) {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]
];
for (let line of lines) {
const [a, b, c] = line;
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
};