feat(curriculum): add lab-tic-tac-toe (#59439)

Co-authored-by: Naomi <accounts+github@nhcarrigan.com>
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Shaun Hamilton
2025-04-18 21:08:07 +02:00
committed by GitHub
parent 4b3f04f12f
commit 62ef8f6033
5 changed files with 580 additions and 6 deletions

View File

@@ -3560,9 +3560,12 @@
"In these lecture videos, you will learn about routing in React, React frameworks, and dependency management tools."
]
},
"vyzp": {
"title": "281",
"intro": []
"lab-tic-tac-toe": {
"title": "Build a Tic-Tac-Toe Game",
"intro": [
"In this lab, you'll build a Tic-Tac-Toe game using React.",
"You'll practice managing state, handling user interactions, and updating the UI dynamically."
]
},
"lecture-react-strategies-and-debugging": {
"title": "React Strategies and Debugging",

View File

@@ -0,0 +1,9 @@
---
title: Introduction to the Build a Tic-Tac-Toe Game
block: lab-tic-tac-toe
superBlock: full-stack-developer
---
## Introduction to the Build a Tic-Tac-Toe Game
In this lab, you'll build a Tic-Tac-Toe game using React. You'll practice managing state, handling user interactions, and updating the UI dynamically.

View File

@@ -0,0 +1,16 @@
{
"name": "Build a Tic-Tac-Toe Game",
"usesMultifileEditor": true,
"dashedName": "lab-tic-tac-toe",
"superBlock": "full-stack-developer",
"challengeOrder": [
{
"id": "67e3a6b7f60b4085588189e6",
"title": "Build a Tic-Tac-Toe Game"
}
],
"helpCategory": "JavaScript",
"isUpcomingChange": false,
"blockLayout": "link",
"blockType": "lab"
}

View File

@@ -0,0 +1,543 @@
---
id: 67e3a6b7f60b4085588189e6
title: Build a Tic-Tac-Toe Game
challengeType: 25
dashedName: build-a-tic-tac-toe-game
demoType: onClick
---
# --description--
**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab.
**User Stories:**
1. You should create a `Board` component that renders nine `button` elements each with a class of `square` in a 3x3 grid.
2. Clicking `button.square` elements should alternate between displaying an `X` then `O` within the element.
3. Once a player has won the game, clicking on any `button.square` should not cause any further changes.
4. You should create a `button#reset` element that resets the game when clicked.
5. A message should be displayed indicating either `X` or `O` as the winner, or neither if the result is a draw.
# --hints--
You should export a `Board` component.
```js
async () => {
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.
```js
const els = document.querySelectorAll("button.square");
assert.equal(els.length, 9);
```
The `button.square` elements should be in a 3x3 grid.
```js
// 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.
```js
async () => {
const el = document.querySelector("button.square");
el.click();
await delay(50);
try {
assert.include(el.textContent, "X");
} catch(e) {
console.error(e);
throw e;
}
}
```
Clicking on the `button#reset` element should reset the game.
```js
async () => {
// NOTE: This test is intentionally high-up, because the latter tests rely on the functionality.
const el = document.querySelector("button.square");
el.click();
await delay(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.
```js
async () => {
await reset(assert);
const els = document.querySelectorAll("button.square");
els[0].click();
await delay(50);
els[1].click();
await delay(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.
```js
async () => {
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();
await delay(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.
```js
async () => {
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();
await delay(50);
els[wrappedI].click();
await delay(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.
```js
async () => {
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();
await delay(50);
assert.include(x.textContent, "X");
const o = els[i + 3];
o.click();
await delay(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`.
```js
async () => {
// 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();
await delay(50);
const o = els[i + 3];
o.click();
await delay(50);
}
const preXWin = getInnerTextExcept("button.square");
// Click winning button for X
els[2].click();
await delay(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();
await delay(50);
const o = els[i + 3];
o.click();
await delay(50);
}
els[6].click();
await delay(50);
const preOWin = getInnerTextExcept("button.square");
// Click winning button for O
els[5].click();
await delay(50);
const postOWin = getInnerTextExcept("button.square");
assert.notEqual(preOWin, postOWin);
// There should be a difference between `O` and `X` winning.
assert.notEqual(postXWin, postOWin);
} catch(e) {
console.error(e);
throw e;
}
}
```
The game should display a message indicating a draw.
```js
async () => {
// Get to almost draw state
// Check dom
// Click final square
// Check dom for change
await reset(assert);
const els = document.querySelectorAll("button.square");
try {
for (let i = 3; i < els.length + 2; i++) {
const wrappedI = i % els.length;
els[wrappedI].click();
await delay(50);
}
const pre = getInnerTextExcept("button.square");
els[2].click();
await delay(50);
const post = getInnerTextExcept("button.square");
assert.notEqual(pre, post);
} catch(e) {
console.error(e);
throw e;
}
}
```
# --before-all--
```js
async function delay(time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
async function reset(assert) {
const reset = document.querySelector("#reset");
assert.exists(reset, "button#reset should exist");
reset.click();
await delay(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;
}
```
# --seed--
## --seed-contents--
```html
<!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>
```
```css
```
```jsx
const { useState } = React;
export function Board() {
}
```
# --solutions--
```html
<!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>
```
```css
* {
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;
}
```
```jsx
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;
};
```

View File

@@ -1087,15 +1087,18 @@
{
"dashedName": "lab-event-rsvp"
},
{
"dashedName": "lab-currency-converter"
},
{
"dashedName": "lecture-working-with-data-fetching-and-memoization-in-react"
},
{
"dashedName": "lab-currency-converter"
},
{
"dashedName": "lecture-routing-react-frameworks-and-dependency-management-tools"
},
{
"dashedName": "lab-tic-tac-toe"
},
{
"dashedName": "lecture-react-strategies-and-debugging"
},