Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
overrides: [{ files: ['tests/**/*.js'], env: { node: true } }],
rules: {
"react/jsx-first-prop-new-line": [1, "multiline"],
'react/prop-types': 'off',
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ yarn && yarn start

More information about the available commands can be found in the [Contributing](CONTRIBUTING.md) guide.

## Validation

Use the Node version in `.nvmrc`. This Vite project provides the following checks:

```bash
npm test
npm run lint
npm run build
```

`npm test` uses Node's built-in test runner, jsdom, and Vite's existing JSX
transform to render the actual React app without starting a web server. The tests
cover clearing completed tasks in all three filter views, disabled button states,
empty lists, focus after clearing, and the existing add/edit/toggle/delete actions.
The test-only jsdom dependency may emit a `punycode` deprecation warning on recent
Node versions.

## Learn More

You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev": "vite --port 3000 --open",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test tests/*.test.js"
},
"dependencies": {
"nanoid": "5.1.16",
Expand All @@ -23,6 +24,7 @@
"eslint-plugin-react": "7.37.2",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.16",
"vite": "5.4.21"
"vite": "5.4.21",
"jsdom": "16.7.0"
}
}
14 changes: 14 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ function App(props) {
setTasks(remainingTasks);
}

function clearCompletedTasks() {
setTasks((currentTasks) => currentTasks.filter((task) => !task.completed));
}

const hasCompletedTasks = tasks.some((task) => task.completed);

function editTask(id, newName) {
const editedTaskList = tasks.map((task) => {
// if this task has the same ID as the edited task
Expand Down Expand Up @@ -100,6 +106,14 @@ function App(props) {
<h1>TodoMatic</h1>
<Form addTask={addTask} />
<div className="filters btn-group stack-exception">{filterList}</div>
<button
type="button"
className="btn"
onClick={clearCompletedTasks}
disabled={!hasCompletedTasks}
>
清除已完成任务
</button>
<h2 id="list-heading" tabIndex="-1" ref={listHeadingRef}>
{headingText}
</h2>
Expand Down
4 changes: 4 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ body {
padding: 0.8rem 1rem 0.7rem;
text-transform: capitalize;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.btn.toggle-btn {
border-color: #d3d3d3;
border-width: 1px;
Expand Down
165 changes: 165 additions & 0 deletions tests/App.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { createElement } from "react";
import { JSDOM } from "jsdom";
import { createServer } from "vite";

let App;
let act;
let createRoot;
let dom;
let root;
let container;

before(async () => {
dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
({ act } = await import("react-dom/test-utils"));
({ createRoot } = await import("react-dom/client"));

// Use the project's JSX transform without starting an HTTP server.
const server = await createServer({
server: { middlewareMode: true, watch: null, hmr: false, ws: false },
appType: "custom",
});
try {
({ default: App } = await server.ssrLoadModule("/src/App.jsx"));
} finally {
await server.close();
}
});

afterEach(() => {
if (root) act(() => root.unmount());
root = undefined;
container?.remove();
});

after(() => {
dom?.window.close();
delete globalThis.window;
delete globalThis.document;
delete globalThis.IS_REACT_ACT_ENVIRONMENT;
});

function render(tasks) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root.render(createElement(App, { tasks })));
}

function button(name) {
const element = [...container.querySelectorAll("button")].find(
(element) => element.textContent.replace(/\s+/g, " ").trim().toLowerCase() === name.toLowerCase(),
);
assert.ok(element, `Button exists: ${name}`);
return element;
}

function click(element) {
act(() => element.click());
}

function names() {
return [...container.querySelectorAll(".todo-label")].map(
(element) => element.textContent,
);
}

const active = { id: "active-1", name: "Keep first", completed: false };
const anotherActive = { id: "active-2", name: "Keep second", completed: false };
const completed = { id: "done-1", name: "Remove first", completed: true };
const anotherCompleted = { id: "done-2", name: "Remove second", completed: true };

const scenarios = [
{ name: "mixed tasks", tasks: [completed, active, anotherCompleted, anotherActive] },
{ name: "all completed", tasks: [completed, anotherCompleted] },
{ name: "none completed", tasks: [active, anotherActive] },
{ name: "empty list", tasks: [] },
];

for (const view of ["All", "Active", "Completed"]) {
for (const scenario of scenarios) {
test(`${scenario.name}: clear and disabled state in ${view} view`, () => {
// Freezing fixtures also catches accidental mutation of the input tasks.
const tasks = Object.freeze(scenario.tasks.map((task) => Object.freeze({ ...task })));
render(tasks);
click(button(`Show ${view} Tasks`));

const clear = button("清除已完成任务");
assert.equal(clear.type, "button");
assert.equal(clear.disabled, !tasks.some((task) => task.completed));
click(clear);

const expected = tasks.filter((task) => !task.completed).map((task) => task.name);
assert.deepEqual(names(), view === "Completed" ? [] : expected);
assert.equal(button(`Show ${view} Tasks`).getAttribute("aria-pressed"), "true");
assert.equal(clear.disabled, true);

// Return to All to prove hidden completed tasks were removed too.
click(button("Show All Tasks"));
assert.deepEqual(names(), expected);
assert.equal(container.querySelector("#list-heading").textContent,
`${expected.length} ${expected.length === 1 ? "task" : "tasks"} remaining`);
assert.ok([...container.querySelectorAll('input[type="checkbox"]')].every(
(checkbox) => !checkbox.checked,
));
click(clear);
assert.deepEqual(names(), expected);
});
}
}

test("button follows completion changes, including changes hidden by filtering", () => {
render([active]);
const clear = button("清除已完成任务");
assert.equal(clear.disabled, true);
click(container.querySelector('input[type="checkbox"]'));
assert.equal(clear.disabled, false);
click(container.querySelector('input[type="checkbox"]'));
assert.equal(clear.disabled, true);
click(container.querySelector('input[type="checkbox"]'));
click(button("Show Active Tasks"));
assert.deepEqual(names(), []);
assert.equal(clear.disabled, false);
click(clear);
assert.equal(clear.disabled, true);
assert.equal(document.activeElement, container.querySelector("#list-heading"));
click(button("Show All Tasks"));
assert.deepEqual(names(), []);
});

function changeInput(input, value) {
act(() => {
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value").set;
setter.call(input, value);
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
});
}

test("add, edit, toggle and individual delete still work after clearing", () => {
render([active, completed]);
click(button("清除已完成任务"));
assert.equal(document.activeElement, container.querySelector("#list-heading"));

changeInput(container.querySelector("#new-todo-input"), "New task");
click(button("Add"));
assert.deepEqual(names(), [active.name, "New task"]);

click(button("Edit New task"));
changeInput(container.querySelector(".todo-text"), "Renamed task");
click(container.querySelector(".todo-edit"));
assert.deepEqual(names(), [active.name, "Renamed task"]);

click(container.querySelectorAll('input[type="checkbox"]')[1]);
assert.equal(button("清除已完成任务").disabled, false);
click(button("Show Completed Tasks"));
assert.deepEqual(names(), ["Renamed task"]);
click(button("Delete Renamed task"));
assert.equal(button("清除已完成任务").disabled, true);
click(button("Show All Tasks"));
assert.deepEqual(names(), [active.name]);
});
Loading