diff --git a/package-lock.json b/package-lock.json index 707183b..2f81e44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^19.1.15", "@types/react-dom": "^19.2.4", "@typescript/native": "npm:typescript@^7.0.2", @@ -2152,9 +2153,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2172,9 +2170,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2192,9 +2187,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2212,9 +2204,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2232,9 +2221,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2252,9 +2238,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2442,6 +2425,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4762,9 +4759,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4786,9 +4780,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4810,9 +4801,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4834,9 +4822,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index cb0df55..308f9f4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:ui": "vitest --ui", "typecheck": "tsc --noEmit", "test:run": "vitest run", - "local-server": "cross-env VITE_THOUGHTS_WEBSOCKET_URL=ws://localhost:2015/games/v1/thoughts-ws VITE_GOLF_WEBSOCKET_URL=ws://localhost:2015/games/v1/golf-ws VITE_THOUGHTS_SIMULATED=false VITE_METRICS_API_URL=http://localhost:2015/metrics/v1 VITE_MITHRIL_API_URL=http://localhost:2015/mithril/v1/wordchain VITE_TRACY_API_URL=http://localhost:2015/portrait/v1/trace VITE_POSTERIZE_API_URL=http://localhost:2015/imagine/v1 vite", + "local-server": "cross-env VITE_THOUGHTS_WEBSOCKET_URL=ws://localhost:2015/games/v1/thoughts-ws VITE_GOLF_WEBSOCKET_URL=ws://localhost:2015/games/v1/golf-ws VITE_THOUGHTS_SIMULATED=false VITE_METRICS_API_URL=http://localhost:2015/metrics/v1 VITE_MITHRIL_API_URL=http://localhost:2015/mithril/v1/wordchain VITE_TRACY_API_URL=http://localhost:2015/portrait/v1/trace VITE_POSTERIZE_API_URL=http://localhost:2015/imagine/v1 VITE_R3DR_API_URL=http://localhost:2015/r3dr/v2 vite", "deploy": "wrangler deploy" }, "dependencies": { @@ -30,6 +30,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^19.1.15", "@types/react-dom": "^19.2.4", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/src/App.tsx b/src/App.tsx index dbc71c4..237b14e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import SystemsPage from './apps/metrics-systems/pages/SystemsPage' import ResilienceGamePage from './apps/metrics-systems/pages/ResilienceGamePage' import MetricsPage from './apps/metrics-systems/pages/MetricsPage' import WordchainsPage from './apps/wordchains/pages/WordchainsPage' +import R3drPage from './apps/r3dr/pages/R3drPage' import NotFoundPage from './core/pages/NotFoundPage' function App() { @@ -30,6 +31,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/r3dr/__tests__/CopyButton.test.tsx b/src/apps/r3dr/__tests__/CopyButton.test.tsx new file mode 100644 index 0000000..2693261 --- /dev/null +++ b/src/apps/r3dr/__tests__/CopyButton.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CopyButton from '../components/CopyButton' + +// fireEvent, not user-event: userEvent.setup() installs its own clipboard +// stub, which would shadow the one under test. + +function stubClipboard(writeText: (text: string) => Promise) { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) +} + +const TEXT = 'https://iili.uk/r/AQA' + +describe('CopyButton', () => { + it('copies the exact text and confirms', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + stubClipboard(writeText) + render() + + fireEvent.click(screen.getByRole('button', { name: `Copy ${TEXT}` })) + + expect(await screen.findByRole('button', { name: 'Copied' })).toHaveTextContent('Copied ✓') + expect(writeText).toHaveBeenCalledWith(TEXT) + }) + + it('admits failure when no copy path works', async () => { + stubClipboard(vi.fn().mockRejectedValue(new Error('denied'))) + document.execCommand = vi.fn().mockReturnValue(false) + render() + + fireEvent.click(screen.getByRole('button', { name: /^Copy / })) + + expect(await screen.findByRole('button', { name: 'Copy failed' })).toBeDefined() + }) + + it('falls back to execCommand when the clipboard API is refused', async () => { + stubClipboard(vi.fn().mockRejectedValue(new Error('denied'))) + document.execCommand = vi.fn().mockReturnValue(true) + render() + + fireEvent.click(screen.getByRole('button', { name: /^Copy / })) + + expect(await screen.findByRole('button', { name: 'Copied' })).toBeDefined() + expect(document.execCommand).toHaveBeenCalledWith('copy') + }) +}) diff --git a/src/apps/r3dr/__tests__/R3drPage.test.tsx b/src/apps/r3dr/__tests__/R3drPage.test.tsx new file mode 100644 index 0000000..0766112 --- /dev/null +++ b/src/apps/r3dr/__tests__/R3drPage.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import R3drPage from '../pages/R3drPage' +import * as api from '../api' + +vi.mock('../api', { spy: true }) +// The shared nav pulls in router-dependent pieces this page doesn't test. +vi.mock('@/shared/components/Navigation', () => ({ default: () =>