From c1b0d6ccf2ab0611727ba1271354959ad30517ea Mon Sep 17 00:00:00 2001 From: leganz Date: Fri, 10 Apr 2026 09:37:44 +0200 Subject: [PATCH 1/4] feat: add tests --- package-lock.json | 456 +++- package.json | 2 + .../VideoPlayer/EndScreen/EndScreen.test.tsx | 606 +++++ .../LogoOverlay/LogoOverlay.test.tsx | 421 ++++ .../ads/AdOverlay/AdOverlay.test.tsx | 208 ++ .../ads/AdSkipButton/AdSkipButton.test.tsx | 107 + .../chapters/ChapterList/ChapterList.test.tsx | 173 ++ .../ChapterMarker/ChapterMarker.test.tsx | 125 ++ .../controls/CastButton/CastButton.test.tsx | 146 ++ .../FullscreenButton.test.tsx | 132 ++ .../NowPlayingIndicator.test.tsx | 151 ++ .../PictureInPictureButton.test.tsx | 139 ++ .../controls/PlayButton/PlayButton.test.tsx | 189 ++ .../PlaybackSpeed/PlaybackSpeed.test.tsx | 163 ++ .../controls/ProgressBar/ProgressBar.test.tsx | 384 ++++ .../controls/SkipButtons/SkipButtons.test.tsx | 160 ++ .../SubtitleSelector.test.tsx | 252 +++ .../controls/TimeDisplay/TimeDisplay.test.tsx | 123 ++ .../VolumeControl/VolumeControl.test.tsx | 426 ++++ .../markers/MarkerList/MarkerList.test.tsx | 175 ++ .../PlaylistView/PlaylistView.test.tsx | 137 ++ .../playlist/TrackItem/TrackItem.test.tsx | 159 ++ src/components/stats/Rating.test.tsx | 278 +++ src/components/stats/Stats.test.tsx | 241 ++ src/context/AdContext.test.tsx | 1443 ++++++++++++ src/context/LabelsContext.test.tsx | 211 ++ src/context/PlayerContext.test.tsx | 511 +++++ src/context/TrackingContext.test.tsx | 658 ++++++ src/context/VideoAdContext.test.tsx | 1952 +++++++++++++++++ src/context/VideoContext.test.tsx | 564 +++++ src/embed/parseConfig.test.ts | 805 +++++++ src/hooks/useAudio.test.ts | 211 ++ src/hooks/useCast.test.ts | 463 ++++ src/hooks/useChapters.test.ts | 342 +++ src/hooks/useFullscreen.test.ts | 436 ++++ src/hooks/useHLS.test.ts | 964 ++++++++ src/hooks/useKeyboardControls.test.ts | 321 +++ src/hooks/useMarkers.test.ts | 378 ++++ src/hooks/useMedia.test.ts | 814 +++++++ src/hooks/usePictureInPicture.test.ts | 353 +++ src/hooks/usePlayer.test.tsx | 152 ++ src/hooks/usePlaylist.test.ts | 565 +++++ src/hooks/useTabVisibility.test.ts | 307 +++ src/hooks/useVideo.test.ts | 1269 +++++++++++ src/services/AdService.test.ts | 563 +++++ src/services/TrackingService.test.ts | 676 ++++++ src/test/helpers.tsx | 258 +++ src/test/setup.ts | 63 + src/utils/PlayerEventBus.test.ts | 491 +++++ src/utils/fairu.test.ts | 565 +++++ vite.config.ts | 5 +- 51 files changed, 20598 insertions(+), 95 deletions(-) create mode 100644 src/components/VideoPlayer/EndScreen/EndScreen.test.tsx create mode 100644 src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx create mode 100644 src/components/ads/AdOverlay/AdOverlay.test.tsx create mode 100644 src/components/ads/AdSkipButton/AdSkipButton.test.tsx create mode 100644 src/components/chapters/ChapterList/ChapterList.test.tsx create mode 100644 src/components/chapters/ChapterMarker/ChapterMarker.test.tsx create mode 100644 src/components/controls/CastButton/CastButton.test.tsx create mode 100644 src/components/controls/FullscreenButton/FullscreenButton.test.tsx create mode 100644 src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx create mode 100644 src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx create mode 100644 src/components/controls/PlayButton/PlayButton.test.tsx create mode 100644 src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx create mode 100644 src/components/controls/ProgressBar/ProgressBar.test.tsx create mode 100644 src/components/controls/SkipButtons/SkipButtons.test.tsx create mode 100644 src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx create mode 100644 src/components/controls/TimeDisplay/TimeDisplay.test.tsx create mode 100644 src/components/controls/VolumeControl/VolumeControl.test.tsx create mode 100644 src/components/markers/MarkerList/MarkerList.test.tsx create mode 100644 src/components/playlist/PlaylistView/PlaylistView.test.tsx create mode 100644 src/components/playlist/TrackItem/TrackItem.test.tsx create mode 100644 src/components/stats/Rating.test.tsx create mode 100644 src/components/stats/Stats.test.tsx create mode 100644 src/context/AdContext.test.tsx create mode 100644 src/context/LabelsContext.test.tsx create mode 100644 src/context/PlayerContext.test.tsx create mode 100644 src/context/TrackingContext.test.tsx create mode 100644 src/context/VideoAdContext.test.tsx create mode 100644 src/context/VideoContext.test.tsx create mode 100644 src/embed/parseConfig.test.ts create mode 100644 src/hooks/useAudio.test.ts create mode 100644 src/hooks/useCast.test.ts create mode 100644 src/hooks/useChapters.test.ts create mode 100644 src/hooks/useFullscreen.test.ts create mode 100644 src/hooks/useHLS.test.ts create mode 100644 src/hooks/useKeyboardControls.test.ts create mode 100644 src/hooks/useMarkers.test.ts create mode 100644 src/hooks/useMedia.test.ts create mode 100644 src/hooks/usePictureInPicture.test.ts create mode 100644 src/hooks/usePlayer.test.tsx create mode 100644 src/hooks/usePlaylist.test.ts create mode 100644 src/hooks/useTabVisibility.test.ts create mode 100644 src/hooks/useVideo.test.ts create mode 100644 src/services/AdService.test.ts create mode 100644 src/services/TrackingService.test.ts create mode 100644 src/test/helpers.tsx create mode 100644 src/utils/PlayerEventBus.test.ts create mode 100644 src/utils/fairu.test.ts diff --git a/package-lock.json b/package-lock.json index a2cbcf8..5f35416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,9 +25,11 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.4", "autoprefixer": "^10.4.20", "jsdom": "^26.0.0", "postcss": "^8.5.1", @@ -273,13 +275,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -365,9 +367,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -378,6 +380,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@chromatic-com/storybook": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-3.2.7.tgz", @@ -2027,6 +2039,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@storybook/addon-interactions/node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@storybook/addon-interactions/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2564,6 +2590,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@storybook/test/node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@storybook/test/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2683,9 +2723,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.5.2", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", - "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -2857,6 +2897,75 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.4", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/expect": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", @@ -2913,13 +3022,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2928,7 +3037,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2940,9 +3049,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -2973,13 +3082,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -2987,36 +3096,37 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner/node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3024,13 +3134,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3039,22 +3150,37 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3386,6 +3512,35 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -4085,9 +4240,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -4616,6 +4771,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4884,6 +5046,58 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5138,6 +5352,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/map-or-similar": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", @@ -6280,9 +6535,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -6976,31 +7231,31 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -7016,12 +7271,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -7042,6 +7300,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -7050,44 +7314,47 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -7095,14 +7362,15 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -7119,9 +7387,9 @@ } }, "node_modules/vitest/node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 19c2487..611063c 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,11 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.4", "autoprefixer": "^10.4.20", "jsdom": "^26.0.0", "postcss": "^8.5.1", diff --git a/src/components/VideoPlayer/EndScreen/EndScreen.test.tsx b/src/components/VideoPlayer/EndScreen/EndScreen.test.tsx new file mode 100644 index 0000000..276ea95 --- /dev/null +++ b/src/components/VideoPlayer/EndScreen/EndScreen.test.tsx @@ -0,0 +1,606 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { EndScreen } from './EndScreen'; +import { RecommendedCard } from './RecommendedCard'; +import { AutoPlayCountdown } from './AutoPlayCountdown'; +import type { EndScreenConfig, RecommendedVideo } from '@/types/video'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function createRecommendedVideo(overrides: Partial = {}): RecommendedVideo { + return { + id: 'rec-1', + title: 'Recommended Video 1', + thumbnail: 'https://example.com/thumb1.jpg', + duration: 120, + channel: 'Test Channel', + channelAvatar: 'https://example.com/avatar.jpg', + views: '1.2M views', + ...overrides, + }; +} + +function createEndScreenConfig(overrides: Partial = {}): EndScreenConfig { + return { + enabled: true, + recommendations: [ + createRecommendedVideo({ id: 'rec-1', title: 'Video One' }), + createRecommendedVideo({ id: 'rec-2', title: 'Video Two' }), + createRecommendedVideo({ id: 'rec-3', title: 'Video Three' }), + ], + ...overrides, + }; +} + +// ─── EndScreen ────────────────────────────────────────────────────── + +describe('EndScreen', () => { + it('renders nothing when not ended and not near end', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when not enabled', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when no recommendations', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders when video has ended', () => { + render( + + ); + expect(screen.getByText('Recommended Videos')).toBeInTheDocument(); + }); + + it('renders when near end (within showAt seconds)', () => { + render( + + ); + expect(screen.getByText('Recommended Videos')).toBeInTheDocument(); + }); + + it('renders custom title', () => { + render( + + ); + expect(screen.getByText('More Videos')).toBeInTheDocument(); + }); + + it('renders replay button when showReplay is true and onReplay is provided', () => { + const onReplay = vi.fn(); + render( + + ); + expect(screen.getByText('Replay')).toBeInTheDocument(); + }); + + it('calls onReplay when replay button is clicked', () => { + const onReplay = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Replay')); + expect(onReplay).toHaveBeenCalledTimes(1); + }); + + it('does not render replay button when showReplay is false', () => { + render( + + ); + expect(screen.queryByText('Replay')).not.toBeInTheDocument(); + }); + + it('does not render replay button when onReplay is not provided', () => { + render( + + ); + expect(screen.queryByText('Replay')).not.toBeInTheDocument(); + }); + + it('renders all recommendation cards in grid layout', () => { + render( + + ); + expect(screen.getByText('Video One')).toBeInTheDocument(); + expect(screen.getByText('Video Two')).toBeInTheDocument(); + expect(screen.getByText('Video Three')).toBeInTheDocument(); + }); + + it('renders recommendations in carousel layout', () => { + render( + + ); + expect(screen.getByText('Video One')).toBeInTheDocument(); + expect(screen.getByText('Video Two')).toBeInTheDocument(); + }); + + it('limits displayed videos to columns * 2 in grid', () => { + const many = Array.from({ length: 10 }, (_, i) => + createRecommendedVideo({ id: `rec-${i}`, title: `Video ${i}` }) + ); + render( + + ); + // columns=2, max = 4 + expect(screen.getByText('Video 0')).toBeInTheDocument(); + expect(screen.getByText('Video 3')).toBeInTheDocument(); + expect(screen.queryByText('Video 4')).not.toBeInTheDocument(); + }); + + it('applies 2-column grid class', () => { + const { container } = render( + + ); + const grid = container.querySelector('.grid'); + expect(grid?.className).toContain('grid-cols-2'); + }); + + it('applies 4-column grid class', () => { + const { container } = render( + + ); + const grid = container.querySelector('.grid'); + expect(grid?.className).toContain('md:grid-cols-4'); + }); + + it('calls onVideoSelect and config.onVideoSelect when a card is clicked', () => { + const onVideoSelect = vi.fn(); + const configOnVideoSelect = vi.fn(); + const config = createEndScreenConfig({ onVideoSelect: configOnVideoSelect }); + render( + + ); + fireEvent.click(screen.getByText('Video One')); + expect(onVideoSelect).toHaveBeenCalledWith(config.recommendations[0]); + expect(configOnVideoSelect).toHaveBeenCalledWith(config.recommendations[0]); + }); + + it('renders autoplay countdown when autoPlayNext is true and video ended', () => { + render( + + ); + // "Up next" appears in both badge and countdown area + const upNextElements = screen.getAllByText('Up next'); + expect(upNextElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('does not render autoplay countdown when video has not ended', () => { + render( + + ); + // The autoplay countdown should not show (it only appears when isEnded=true). + // However, the first card still shows an "Up next" badge. + // The countdown-specific text is "Cancel" which should not appear. + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); + }); + + it('hides autoplay countdown after cancel', () => { + render( + + ); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByText('Up next')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + expect(container.querySelector('.fairu-end-screen')?.className).toContain('my-custom-end'); + }); + + it('marks first card as "up next" when autoPlayNext is enabled', () => { + render( + + ); + // "Up next" appears both as badge on card and in the autoplay countdown + const upNextElements = screen.getAllByText('Up next'); + expect(upNextElements.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ─── RecommendedCard ──────────────────────────────────────────────── + +describe('RecommendedCard', () => { + it('renders video title', () => { + const video = createRecommendedVideo({ title: 'My Great Video' }); + render(); + expect(screen.getByText('My Great Video')).toBeInTheDocument(); + }); + + it('renders thumbnail image', () => { + const video = createRecommendedVideo({ thumbnail: 'https://example.com/thumb.jpg' }); + render(); + const img = screen.getByAltText('Recommended Video 1'); + expect(img).toBeInTheDocument(); + }); + + it('renders duration badge', () => { + const video = createRecommendedVideo({ duration: 125 }); + render(); + // 125 seconds = 2:05 + expect(screen.getByText('2:05')).toBeInTheDocument(); + }); + + it('does not render duration badge when duration is undefined', () => { + const video = createRecommendedVideo({ duration: undefined }); + render(); + expect(screen.queryByText(/^\d+:\d+$/)).not.toBeInTheDocument(); + }); + + it('renders channel name', () => { + const video = createRecommendedVideo({ channel: 'Cool Channel' }); + render(); + expect(screen.getByText('Cool Channel')).toBeInTheDocument(); + }); + + it('renders channel avatar when provided', () => { + const video = createRecommendedVideo({ + channelAvatar: 'https://example.com/avatar.jpg', + channel: 'Cool Channel', + }); + render(); + const avatarImg = screen.getByAltText('Cool Channel'); + expect(avatarImg).toBeInTheDocument(); + expect(avatarImg.getAttribute('src')).toBe('https://example.com/avatar.jpg'); + }); + + it('does not render channel avatar when not provided', () => { + const video = createRecommendedVideo({ channelAvatar: undefined, channel: 'Cool Channel' }); + render(); + expect(screen.queryByAltText('Cool Channel')).not.toBeInTheDocument(); + }); + + it('renders views count', () => { + const video = createRecommendedVideo({ views: '1.2M views' }); + render(); + expect(screen.getByText('1.2M views')).toBeInTheDocument(); + }); + + it('does not render views when not provided', () => { + const video = createRecommendedVideo({ views: undefined }); + render(); + expect(screen.queryByText(/views/)).not.toBeInTheDocument(); + }); + + it('calls onSelect when clicked', () => { + const onSelect = vi.fn(); + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(onSelect).toHaveBeenCalledWith(video); + }); + + it('calls onSelect when Enter key is pressed', () => { + const onSelect = vi.fn(); + const video = createRecommendedVideo(); + render(); + fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' }); + expect(onSelect).toHaveBeenCalledWith(video); + }); + + it('opens external URL when video has url but no src', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null); + const video = createRecommendedVideo({ url: 'https://example.com/watch', src: undefined }); + render(); + fireEvent.click(screen.getByRole('button')); + expect(windowOpen).toHaveBeenCalledWith('https://example.com/watch', '_blank'); + windowOpen.mockRestore(); + }); + + it('does not open external URL when video has src', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null); + const video = createRecommendedVideo({ url: 'https://example.com/watch', src: 'https://example.com/video.mp4' }); + render(); + fireEvent.click(screen.getByRole('button')); + expect(windowOpen).not.toHaveBeenCalled(); + windowOpen.mockRestore(); + }); + + it('shows "Up next" badge when isUpNext is true', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByText('Up next')).toBeInTheDocument(); + }); + + it('does not show "Up next" badge by default', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.queryByText('Up next')).not.toBeInTheDocument(); + }); + + it('applies ring styling when isUpNext', () => { + const video = createRecommendedVideo(); + const { container } = render(); + const card = container.querySelector('.fairu-recommended-card'); + expect(card?.className).toContain('ring-2'); + }); + + it('applies custom className', () => { + const video = createRecommendedVideo(); + const { container } = render(); + const card = container.querySelector('.fairu-recommended-card'); + expect(card?.className).toContain('my-card'); + }); + + it('has tabIndex 0 for keyboard accessibility', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByRole('button')).toHaveAttribute('tabindex', '0'); + }); + + it('renders without channel info', () => { + const video = createRecommendedVideo({ channel: undefined, channelAvatar: undefined }); + render(); + expect(screen.getByText(video.title)).toBeInTheDocument(); + }); +}); + +// ─── AutoPlayCountdown ───────────────────────────────────────────── + +describe('AutoPlayCountdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders when active', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByText('Up next')).toBeInTheDocument(); + expect(screen.getByText(video.title)).toBeInTheDocument(); + }); + + it('renders nothing when not active', () => { + const video = createRecommendedVideo(); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows countdown number', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('counts down every second', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByText('5')).toBeInTheDocument(); + + act(() => { vi.advanceTimersByTime(1000); }); + expect(screen.getByText('4')).toBeInTheDocument(); + + act(() => { vi.advanceTimersByTime(1000); }); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('calls onComplete when countdown reaches 0', () => { + const onComplete = vi.fn(); + const video = createRecommendedVideo(); + render(); + + act(() => { vi.advanceTimersByTime(3000); }); + expect(onComplete).toHaveBeenCalledWith(video); + }); + + it('shows cancel button when counting down', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onCancel when cancel is clicked', () => { + const onCancel = vi.fn(); + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('hides cancel button after cancelling', () => { + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); + }); + + it('shows "Autoplay paused" text after cancel', () => { + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.getByText('Autoplay paused')).toBeInTheDocument(); + }); + + it('shows "Play" button text when paused', () => { + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.getByText('Play')).toBeInTheDocument(); + }); + + it('shows "Play now" button when actively counting', () => { + const video = createRecommendedVideo(); + render(); + expect(screen.getByText('Play now')).toBeInTheDocument(); + }); + + it('calls onComplete when "Play now" is clicked', () => { + const onComplete = vi.fn(); + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Play now')); + expect(onComplete).toHaveBeenCalledWith(video); + }); + + it('stops countdown after cancel', () => { + const onComplete = vi.fn(); + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Cancel')); + act(() => { vi.advanceTimersByTime(5000); }); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('renders channel name when provided', () => { + const video = createRecommendedVideo({ channel: 'My Channel' }); + render(); + expect(screen.getByText('My Channel')).toBeInTheDocument(); + }); + + it('does not render channel name when not provided', () => { + const video = createRecommendedVideo({ channel: undefined }); + render(); + expect(screen.queryByText('Test Channel')).not.toBeInTheDocument(); + }); + + it('renders thumbnail image', () => { + const video = createRecommendedVideo({ thumbnail: 'https://example.com/thumb.jpg' }); + render(); + const img = screen.getByAltText(video.title); + expect(img).toBeInTheDocument(); + expect(img.getAttribute('src')).toBe('https://example.com/thumb.jpg'); + }); + + it('applies custom className', () => { + const video = createRecommendedVideo(); + const { container } = render( + + ); + expect(container.querySelector('.fairu-autoplay-countdown')?.className).toContain('my-countdown'); + }); + + it('calls onComplete via play button when paused', () => { + const onComplete = vi.fn(); + const video = createRecommendedVideo(); + render(); + fireEvent.click(screen.getByText('Cancel')); + fireEvent.click(screen.getByText('Play')); + expect(onComplete).toHaveBeenCalledWith(video); + }); +}); diff --git a/src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx b/src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx new file mode 100644 index 0000000..cd7b5d2 --- /dev/null +++ b/src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx @@ -0,0 +1,421 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LogoOverlay } from './LogoOverlay'; +import type { LogoConfig, LogoComponentProps } from '@/types/logo'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function createLogoConfig(overrides: Partial = {}): LogoConfig { + return { + src: 'https://example.com/logo.png', + alt: 'Test Logo', + ...overrides, + }; +} + +describe('LogoOverlay', () => { + // ── Basic rendering ─────────────────────────────────────────────── + + it('renders nothing when neither src nor component is provided', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders logo image when src is provided', () => { + render(); + const img = screen.getByAltText('Test Logo'); + expect(img).toBeInTheDocument(); + expect(img.getAttribute('src')).toBe('https://example.com/logo.png'); + }); + + it('renders image with empty alt text by default', () => { + const { container } = render(); + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img?.getAttribute('alt')).toBe(''); + }); + + it('image is not draggable', () => { + render(); + const img = screen.getByAltText('Test Logo'); + expect(img.getAttribute('draggable')).toBe('false'); + }); + + // ── Positioning ─────────────────────────────────────────────────── + + it('positions at top-left', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.top).toBe('16px'); + expect(el.style.left).toBe('16px'); + }); + + it('positions at top-right', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.top).toBe('16px'); + expect(el.style.right).toBe('16px'); + }); + + it('positions at bottom-left', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + // bottom = margin(16) + CONTROLS_HEIGHT(56) + offsetY(0) = 72 + expect(el.style.bottom).toBe('72px'); + expect(el.style.left).toBe('16px'); + }); + + it('positions at bottom-right (default)', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.bottom).toBe('72px'); + expect(el.style.right).toBe('16px'); + }); + + it('applies custom margin', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.top).toBe('24px'); + expect(el.style.left).toBe('24px'); + }); + + it('applies offsetX and offsetY', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + // top = margin(16) + offsetY(5) = 21 + expect(el.style.top).toBe('21px'); + // left = margin(16) + offsetX(10) = 26 + expect(el.style.left).toBe('26px'); + }); + + // ── Dimensions ──────────────────────────────────────────────────── + + it('applies custom width and height to image', () => { + render( + + ); + const img = screen.getByAltText('Test Logo'); + expect(img.style.width).toBe('200px'); + expect(img.style.height).toBe('80px'); + expect(img.style.maxWidth).toBe('200px'); + expect(img.style.maxHeight).toBe('80px'); + }); + + it('applies default max dimensions when width/height not specified', () => { + render(); + const img = screen.getByAltText('Test Logo'); + expect(img.style.width).toBe('auto'); + expect(img.style.height).toBe('auto'); + expect(img.style.maxWidth).toBe('120px'); + expect(img.style.maxHeight).toBe('60px'); + }); + + // ── Opacity ─────────────────────────────────────────────────────── + + it('applies default opacity (0.8)', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.opacity).toBe('0.8'); + }); + + it('applies custom opacity', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.opacity).toBe('0.5'); + }); + + // ── Click handler ───────────────────────────────────────────────── + + it('calls onClick when clicked (div wrapper)', () => { + const onClick = vi.fn(); + const { container } = render( + + ); + fireEvent.click(container.firstChild as HTMLElement); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('applies cursor-pointer class when onClick is provided', () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain('cursor-pointer'); + }); + + // ── Link (href) ─────────────────────────────────────────────────── + + it('renders as anchor when href is provided', () => { + const { container } = render( + + ); + const link = container.querySelector('a'); + expect(link).toBeInTheDocument(); + expect(link?.getAttribute('href')).toBe('https://example.com'); + }); + + it('opens link in new tab by default', () => { + const { container } = render( + + ); + const link = container.querySelector('a'); + expect(link?.getAttribute('target')).toBe('_blank'); + expect(link?.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('opens link in same tab when target is _self', () => { + const { container } = render( + + ); + const link = container.querySelector('a'); + expect(link?.getAttribute('target')).toBe('_self'); + expect(link?.getAttribute('rel')).toBeNull(); + }); + + it('calls onClick and prevents default when both href and onClick are set', () => { + const onClick = vi.fn(); + const { container } = render( + + ); + const link = container.querySelector('a')!; + fireEvent.click(link); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + // ── Animations ──────────────────────────────────────────────────── + + it('applies fade animation by default', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transitionProperty).toBe('opacity, transform'); + expect(el.style.opacity).toBe('0.8'); + }); + + it('applies none animation with 0ms duration', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transitionDuration).toBe('0ms'); + }); + + it('applies slide animation with translateX(0) when visible', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transform).toBe('translateX(0)'); + }); + + it('applies slide animation with translateX(100%) when hidden on right side', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transform).toBe('translateX(100%)'); + }); + + it('applies slide animation with translateX(-100%) when hidden on left side', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transform).toBe('translateX(-100%)'); + }); + + it('applies scale animation with scale(1) when visible', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transform).toBe('scale(1)'); + }); + + it('applies scale animation with scale(0.75) when hidden', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transform).toBe('scale(0.75)'); + }); + + it('applies custom animation duration', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transitionDuration).toBe('500ms'); + }); + + it('applies custom animation delay', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.transitionDelay).toBe('200ms'); + }); + + // ── hideWithControls ────────────────────────────────────────────── + + it('sets opacity to 0 when hideWithControls is true and visible is false', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.opacity).toBe('0'); + }); + + it('keeps full opacity when hideWithControls is true and visible is true', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.style.opacity).toBe('0.8'); + }); + + it('is always visible when hideWithControls is false', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + // When hideWithControls is false, isVisible is always true + expect(el.style.opacity).toBe('0.8'); + }); + + it('applies pointer-events-none when not visible', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.className).toContain('pointer-events-none'); + }); + + // ── Custom component ────────────────────────────────────────────── + + it('renders custom component instead of image', () => { + function CustomLogo(props: LogoComponentProps) { + return
Custom {props.isPlaying ? 'Playing' : 'Paused'}
; + } + render( + + ); + expect(screen.getByTestId('custom-logo')).toBeInTheDocument(); + expect(screen.getByText('Custom Playing')).toBeInTheDocument(); + }); + + it('passes visibility props to custom component', () => { + function CustomLogo(props: LogoComponentProps) { + return ( +
+ {props.visible ? 'visible' : 'hidden'} + {props.isFullscreen ? ' fullscreen' : ''} +
+ ); + } + render( + + ); + expect(screen.getByText('visible fullscreen')).toBeInTheDocument(); + }); + + it('prefers custom component over src', () => { + function CustomLogo() { + return
Custom
; + } + render( + + ); + expect(screen.getByTestId('custom-logo')).toBeInTheDocument(); + expect(screen.queryByAltText('Test Logo')).not.toBeInTheDocument(); + }); + + // ── className ───────────────────────────────────────────────────── + + it('applies custom className prop', () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain('my-overlay'); + }); + + it('applies config className', () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain('config-class'); + }); + + // ── Absolute positioning class ──────────────────────────────────── + + it('has absolute positioning class', () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain('absolute'); + }); + + it('has z-index class', () => { + const { container } = render( + + ); + expect((container.firstChild as HTMLElement).className).toContain('z-[15]'); + }); +}); diff --git a/src/components/ads/AdOverlay/AdOverlay.test.tsx b/src/components/ads/AdOverlay/AdOverlay.test.tsx new file mode 100644 index 0000000..5cbf5eb --- /dev/null +++ b/src/components/ads/AdOverlay/AdOverlay.test.tsx @@ -0,0 +1,208 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AdOverlay } from './AdOverlay'; +import { createMockAd, createMockAdBreak } from '@/test/helpers'; +import type { AdState, AdControls } from '@/types/ads'; + +function createMockAdState(overrides: Partial = {}): AdState { + return { + isPlayingAd: true, + currentAd: createMockAd(), + currentAdBreak: createMockAdBreak(), + adProgress: 5, + adDuration: 15, + canSkip: false, + skipCountdown: 3, + adsRemaining: 0, + ...overrides, + }; +} + +function createMockControls(overrides: Partial> = {}) { + return { + skipAd: vi.fn(), + clickThrough: vi.fn(), + ...overrides, + }; +} + +function renderAdOverlay( + stateOverrides: Partial = {}, + controlOverrides: Partial> = {}, + className?: string +) { + const state = createMockAdState(stateOverrides); + const controls = createMockControls(controlOverrides); + return render( + + ); +} + +describe('AdOverlay', () => { + // ── Rendering ────────────────────────────────────────────────────── + + it('renders the ad overlay when isPlayingAd is true', () => { + renderAdOverlay(); + expect(screen.getByRole('dialog', { name: 'Advertisement' })).toBeInTheDocument(); + }); + + it('renders the "Ad" badge', () => { + renderAdOverlay(); + expect(screen.getByText('Ad')).toBeInTheDocument(); + }); + + it('does not render when isPlayingAd is false', () => { + renderAdOverlay({ isPlayingAd: false }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('does not render when currentAd is null', () => { + renderAdOverlay({ currentAd: null }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // ── Ad title ─────────────────────────────────────────────────────── + + it('shows ad title when provided', () => { + renderAdOverlay({ + currentAd: createMockAd({ title: 'My Great Ad' }), + }); + expect(screen.getByText('My Great Ad')).toBeInTheDocument(); + }); + + it('does not show title when not provided', () => { + renderAdOverlay({ + currentAd: createMockAd({ title: undefined }), + }); + expect(screen.queryByText('My Great Ad')).not.toBeInTheDocument(); + }); + + // ── Remaining time display ───────────────────────────────────────── + + it('shows remaining time countdown', () => { + renderAdOverlay({ adProgress: 5, adDuration: 15 }); + // remaining = ceil(15 - 5) = 10 + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('seconds remaining')).toBeInTheDocument(); + }); + + it('shows 0 remaining time when ad is complete', () => { + renderAdOverlay({ adProgress: 15, adDuration: 15 }); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('calculates remaining correctly for partial progress', () => { + renderAdOverlay({ adProgress: 12.3, adDuration: 15 }); + // remaining = ceil(15 - 12.3) = ceil(2.7) = 3 + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + // ── Progress bar ─────────────────────────────────────────────────── + + it('renders a progress bar', () => { + const { container } = renderAdOverlay({ adProgress: 7.5, adDuration: 15 }); + const progressBar = container.querySelector('[style*="width"]'); + expect(progressBar).toBeTruthy(); + // 7.5 / 15 * 100 = 50% + expect(progressBar?.getAttribute('style')).toContain('50%'); + }); + + it('shows 0% progress when adDuration is 0', () => { + const { container } = renderAdOverlay({ adProgress: 0, adDuration: 0 }); + const progressBar = container.querySelector('[style*="width"]'); + expect(progressBar?.getAttribute('style')).toContain('0%'); + }); + + // ── Ads remaining ────────────────────────────────────────────────── + + it('shows ads remaining count when greater than 0', () => { + renderAdOverlay({ adsRemaining: 2 }); + expect(screen.getByText('3 ads')).toBeInTheDocument(); // adsRemaining + 1 + }); + + it('does not show ads remaining count when 0', () => { + renderAdOverlay({ adsRemaining: 0 }); + expect(screen.queryByText(/ads$/)).not.toBeInTheDocument(); + }); + + // ── Skip button ──────────────────────────────────────────────────── + + it('renders AdSkipButton with skip countdown when canSkip is false', () => { + renderAdOverlay({ canSkip: false, skipCountdown: 5 }); + expect(screen.getByText('Skip in 5s')).toBeInTheDocument(); + }); + + it('renders "Skip Ad" button when canSkip is true', () => { + renderAdOverlay({ canSkip: true }); + expect(screen.getByText('Skip Ad')).toBeInTheDocument(); + }); + + it('calls controls.skipAd when skip button is clicked', () => { + const skipAd = vi.fn(); + renderAdOverlay({ canSkip: true }, { skipAd }); + fireEvent.click(screen.getByText('Skip Ad')); + expect(skipAd).toHaveBeenCalledTimes(1); + }); + + // ── Click through ────────────────────────────────────────────────── + + it('shows "Click to learn more" when ad has clickThroughUrl', () => { + renderAdOverlay({ + currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }), + }); + expect(screen.getByText('Click to learn more')).toBeInTheDocument(); + }); + + it('does not show click prompt when no clickThroughUrl', () => { + renderAdOverlay({ + currentAd: createMockAd({ clickThroughUrl: undefined }), + }); + expect(screen.queryByText('Click to learn more')).not.toBeInTheDocument(); + }); + + it('calls controls.clickThrough when clickable area is clicked', () => { + const clickThrough = vi.fn(); + renderAdOverlay( + { currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }) }, + { clickThrough } + ); + fireEvent.click(screen.getByRole('button', { name: 'Click to learn more' })); + expect(clickThrough).toHaveBeenCalledTimes(1); + }); + + it('handles keyboard Enter on clickable area', () => { + const clickThrough = vi.fn(); + renderAdOverlay( + { currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }) }, + { clickThrough } + ); + const clickArea = screen.getByRole('button', { name: 'Click to learn more' }); + fireEvent.keyDown(clickArea, { key: 'Enter' }); + expect(clickThrough).toHaveBeenCalledTimes(1); + }); + + it('handles keyboard Space on clickable area', () => { + const clickThrough = vi.fn(); + renderAdOverlay( + { currentAd: createMockAd({ clickThroughUrl: 'https://example.com' }) }, + { clickThrough } + ); + const clickArea = screen.getByRole('button', { name: 'Click to learn more' }); + fireEvent.keyDown(clickArea, { key: ' ' }); + expect(clickThrough).toHaveBeenCalledTimes(1); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className', () => { + renderAdOverlay({}, {}, 'my-ad-overlay'); + expect(screen.getByRole('dialog').className).toContain('my-ad-overlay'); + }); + + // ── Accessibility ────────────────────────────────────────────────── + + it('has aria-live="polite" for screen readers', () => { + renderAdOverlay(); + expect(screen.getByRole('dialog')).toHaveAttribute('aria-live', 'polite'); + }); +}); diff --git a/src/components/ads/AdSkipButton/AdSkipButton.test.tsx b/src/components/ads/AdSkipButton/AdSkipButton.test.tsx new file mode 100644 index 0000000..f535cdf --- /dev/null +++ b/src/components/ads/AdSkipButton/AdSkipButton.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AdSkipButton } from './AdSkipButton'; + +describe('AdSkipButton', () => { + // ── Countdown display ────────────────────────────────────────────── + + it('shows countdown text when cannot skip', () => { + render(); + expect(screen.getByText('Skip in 5s')).toBeInTheDocument(); + }); + + it('shows updated countdown value', () => { + render(); + expect(screen.getByText('Skip in 3s')).toBeInTheDocument(); + }); + + it('shows countdown of 1', () => { + render(); + expect(screen.getByText('Skip in 1s')).toBeInTheDocument(); + }); + + it('renders countdown as a non-interactive div (not a button)', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + // ── Skip enabled ─────────────────────────────────────────────────── + + it('shows "Skip Ad" button when canSkip is true', () => { + render(); + expect(screen.getByText('Skip Ad')).toBeInTheDocument(); + }); + + it('renders as a button when canSkip is true', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders a skip icon SVG when canSkip is true', () => { + render(); + const btn = screen.getByRole('button'); + const svg = btn.querySelector('svg'); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute('aria-hidden')).toBe('true'); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onClick when skip button is clicked', () => { + const onClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not crash when onClick is undefined', () => { + render(); + expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow(); + }); + + // ── Nothing rendered ─────────────────────────────────────────────── + + it('renders nothing when canSkip is false and countdown is 0', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when canSkip is false and countdown is negative', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes className to skip button', () => { + render(); + expect(screen.getByRole('button').className).toContain('my-skip'); + }); + + it('passes className to countdown div', () => { + const { container } = render( + + ); + const div = container.firstElementChild; + expect(div?.className).toContain('my-countdown'); + }); + + // ── Button type ──────────────────────────────────────────────────── + + it('has type="button" on the skip button', () => { + render(); + expect(screen.getByRole('button').getAttribute('type')).toBe('button'); + }); + + // ── Styling ──────────────────────────────────────────────────────── + + it('applies shadow class to skip button', () => { + render(); + expect(screen.getByRole('button').className).toContain('shadow-lg'); + }); + + it('applies backdrop-blur to countdown', () => { + const { container } = render(); + expect(container.firstElementChild?.className).toContain('backdrop-blur-sm'); + }); +}); diff --git a/src/components/chapters/ChapterList/ChapterList.test.tsx b/src/components/chapters/ChapterList/ChapterList.test.tsx new file mode 100644 index 0000000..f2a0c97 --- /dev/null +++ b/src/components/chapters/ChapterList/ChapterList.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChapterList } from './ChapterList'; +import { createMockChapters } from '@/test/helpers'; +import type { Chapter } from '@/types/player'; + +const chapters = createMockChapters() as Chapter[]; + +function renderChapterList(props: Partial[0]> = {}) { + const defaults = { + chapters, + currentChapterIndex: 0, + currentTime: 0, + duration: 180, + onChapterClick: vi.fn(), + }; + return render(); +} + +describe('ChapterList', () => { + // ── Rendering ────────────────────────────────────────────────────── + + it('renders the "Chapters" heading', () => { + renderChapterList(); + expect(screen.getByText('Chapters')).toBeInTheDocument(); + }); + + it('renders all chapter titles', () => { + renderChapterList(); + expect(screen.getByText('Intro')).toBeInTheDocument(); + expect(screen.getByText('Main Content')).toBeInTheDocument(); + expect(screen.getByText('Outro')).toBeInTheDocument(); + }); + + it('renders a list with chapter items', () => { + renderChapterList(); + expect(screen.getByRole('list', { name: 'Chapter list' })).toBeInTheDocument(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + }); + + it('does not render when chapters array is empty', () => { + const { container } = renderChapterList({ chapters: [] }); + expect(container.innerHTML).toBe(''); + }); + + // ── Active chapter highlighting ──────────────────────────────────── + + it('marks the active chapter with aria-current', () => { + renderChapterList({ currentChapterIndex: 1 }); + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).not.toHaveAttribute('aria-current'); + expect(buttons[1]).toHaveAttribute('aria-current', 'true'); + expect(buttons[2]).not.toHaveAttribute('aria-current'); + }); + + it('applies active background styling to current chapter', () => { + renderChapterList({ currentChapterIndex: 0 }); + const buttons = screen.getAllByRole('button'); + expect(buttons[0].className).toContain('bg-[var(--fp-color-surface)]'); + }); + + it('shows active indicator dot for current chapter', () => { + renderChapterList({ currentChapterIndex: 1 }); + const activeButton = screen.getAllByRole('button')[1]; + const dot = activeButton.querySelector('.rounded-full.bg-\\[var\\(--fp-color-primary\\)\\]'); + expect(dot).toBeTruthy(); + }); + + it('applies active text color to current chapter title', () => { + renderChapterList({ currentChapterIndex: 0 }); + const introText = screen.getByText('Intro'); + expect(introText.className).toContain('text-[var(--fp-color-primary)]'); + expect(introText.className).toContain('font-medium'); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onChapterClick with chapter and index when clicked', () => { + const onChapterClick = vi.fn(); + renderChapterList({ onChapterClick }); + fireEvent.click(screen.getByText('Main Content')); + expect(onChapterClick).toHaveBeenCalledWith(chapters[1], 1); + }); + + it('calls onChapterClick for first chapter', () => { + const onChapterClick = vi.fn(); + renderChapterList({ onChapterClick }); + fireEvent.click(screen.getByText('Intro')); + expect(onChapterClick).toHaveBeenCalledWith(chapters[0], 0); + }); + + it('calls onChapterClick for last chapter', () => { + const onChapterClick = vi.fn(); + renderChapterList({ onChapterClick }); + fireEvent.click(screen.getByText('Outro')); + expect(onChapterClick).toHaveBeenCalledWith(chapters[2], 2); + }); + + // ── showDuration ─────────────────────────────────────────────────── + + it('shows start times when showDuration is true (default)', () => { + renderChapterList(); + expect(screen.getByText('0:00')).toBeInTheDocument(); + // 0:30 appears for both chapter 1 startTime (30s) and chapter 1 duration (30s) + expect(screen.getAllByText('0:30').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('2:00')).toBeInTheDocument(); + }); + + it('shows chapter durations when showDuration is true', () => { + renderChapterList(); + // Chapter 1: 0-30 = 30s, Chapter 2: 30-120 = 90s, Chapter 3: 120-180 = 60s + // formatTime(30) = '0:30', formatTime(90) = '1:30', formatTime(60) = '1:00' + expect(screen.getAllByText('0:30').length).toBeGreaterThanOrEqual(1); // start time and/or duration + expect(screen.getByText('1:30')).toBeInTheDocument(); + expect(screen.getByText('1:00')).toBeInTheDocument(); + }); + + it('hides durations when showDuration is false', () => { + renderChapterList({ showDuration: false }); + // The formatted times should not be present + expect(screen.queryByText('0:00')).not.toBeInTheDocument(); + expect(screen.queryByText('1:30')).not.toBeInTheDocument(); + }); + + // ── showImage ────────────────────────────────────────────────────── + + it('shows chapter images when showImage is true and images exist', () => { + const chaptersWithImages = chapters.map((ch) => ({ + ...ch, + image: `https://example.com/${ch.id}.jpg`, + })); + const { container } = renderChapterList({ chapters: chaptersWithImages, showImage: true }); + const images = container.querySelectorAll('img'); + expect(images.length).toBe(3); + }); + + it('does not show images when showImage is false', () => { + const chaptersWithImages = chapters.map((ch) => ({ + ...ch, + image: `https://example.com/${ch.id}.jpg`, + })); + const { container } = renderChapterList({ chapters: chaptersWithImages, showImage: false }); + expect(container.querySelectorAll('img')).toHaveLength(0); + }); + + it('does not show images when chapters have no image property', () => { + const { container } = renderChapterList({ showImage: true }); + expect(container.querySelectorAll('img')).toHaveLength(0); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the container', () => { + const { container } = renderChapterList({ className: 'my-chapters' }); + expect(container.firstElementChild?.className).toContain('my-chapters'); + }); + + // ── Chapter without endTime ──────────────────────────────────────── + + it('calculates duration from next chapter start when endTime is absent', () => { + const chaptersNoEnd: Chapter[] = [ + { id: 'ch-1', title: 'Part 1', startTime: 0 }, + { id: 'ch-2', title: 'Part 2', startTime: 60 }, + ]; + renderChapterList({ chapters: chaptersNoEnd, duration: 120 }); + // Part 1 duration: 60-0 = 60s -> '1:00', Part 2 duration: 120-60 = 60s -> '1:00' + // Part 1: startTime=0 "0:00", duration=60 "1:00" + // Part 2: startTime=60 "1:00", duration=60 "1:00" + // Total "1:00" appearances = 3 (Part1 dur + Part2 start + Part2 dur) + expect(screen.getAllByText('1:00').length).toBe(3); + }); +}); diff --git a/src/components/chapters/ChapterMarker/ChapterMarker.test.tsx b/src/components/chapters/ChapterMarker/ChapterMarker.test.tsx new file mode 100644 index 0000000..504a7ed --- /dev/null +++ b/src/components/chapters/ChapterMarker/ChapterMarker.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChapterMarker } from './ChapterMarker'; +import type { Chapter } from '@/types/player'; + +const chapter: Chapter = { + id: 'ch-1', + title: 'Introduction', + startTime: 30, +}; + +function renderChapterMarker(props: Partial[0]> = {}) { + const defaults = { + chapter, + duration: 300, + isActive: false, + onClick: vi.fn(), + }; + return render(); +} + +describe('ChapterMarker', () => { + // ── Rendering ────────────────────────────────────────────────────── + + it('renders as a button', () => { + renderChapterMarker(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('has type="button"', () => { + renderChapterMarker(); + expect(screen.getByRole('button').getAttribute('type')).toBe('button'); + }); + + it('has an aria-label with the chapter title', () => { + renderChapterMarker(); + expect(screen.getByRole('button', { name: 'Chapter: Introduction' })).toBeInTheDocument(); + }); + + it('has a title attribute with the chapter title', () => { + renderChapterMarker(); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Introduction'); + }); + + // ── Position calculation ─────────────────────────────────────────── + + it('calculates left position as percentage of duration', () => { + renderChapterMarker({ chapter: { ...chapter, startTime: 60 }, duration: 300 }); + const btn = screen.getByRole('button'); + // 60 / 300 * 100 = 20% + expect(btn.style.left).toBe('20%'); + }); + + it('calculates 0% position for startTime 0', () => { + renderChapterMarker({ chapter: { ...chapter, startTime: 0 }, duration: 300 }); + expect(screen.getByRole('button').style.left).toBe('0%'); + }); + + it('calculates position for a chapter near the end', () => { + renderChapterMarker({ chapter: { ...chapter, startTime: 270 }, duration: 300 }); + // 270 / 300 * 100 = 90% + expect(screen.getByRole('button').style.left).toBe('90%'); + }); + + it('handles zero duration gracefully (0% position)', () => { + renderChapterMarker({ duration: 0 }); + expect(screen.getByRole('button').style.left).toBe('0%'); + }); + + // ── Active state ─────────────────────────────────────────────────── + + it('applies active styling when isActive is true', () => { + renderChapterMarker({ isActive: true }); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('bg-[var(--fp-color-primary)]'); + expect(btn.className).toContain('z-10'); + }); + + it('applies inactive styling when isActive is false', () => { + renderChapterMarker({ isActive: false }); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('opacity-40'); + expect(btn.className).not.toContain('z-10'); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onClick with the chapter when clicked', () => { + const onClick = vi.fn(); + renderChapterMarker({ onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledWith(chapter); + }); + + it('calls onClick with a different chapter', () => { + const onClick = vi.fn(); + const otherChapter: Chapter = { id: 'ch-2', title: 'Middle', startTime: 100 }; + renderChapterMarker({ chapter: otherChapter, onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledWith(otherChapter); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className', () => { + renderChapterMarker({ className: 'my-marker' }); + expect(screen.getByRole('button').className).toContain('my-marker'); + }); + + // ── Absolute positioning ─────────────────────────────────────────── + + it('has absolute positioning class', () => { + renderChapterMarker(); + expect(screen.getByRole('button').className).toContain('absolute'); + }); + + // ── Different chapters ───────────────────────────────────────────── + + it('renders correct label for a different chapter title', () => { + renderChapterMarker({ + chapter: { id: 'ch-x', title: 'The Finale', startTime: 200 }, + }); + expect(screen.getByRole('button', { name: 'Chapter: The Finale' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/controls/CastButton/CastButton.test.tsx b/src/components/controls/CastButton/CastButton.test.tsx new file mode 100644 index 0000000..db07da4 --- /dev/null +++ b/src/components/controls/CastButton/CastButton.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CastButton } from './CastButton'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderCastButton(props: Partial[0]> = {}) { + const defaults = { + isCasting: false, + onClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('CastButton', () => { + // ── Not casting state ─────────────────────────────────────────────── + + it('renders cast label when not casting', () => { + renderCastButton({ isCasting: false }); + expect(screen.getByRole('button', { name: 'Cast' })).toBeInTheDocument(); + }); + + it('shows cast title when not casting', () => { + renderCastButton({ isCasting: false }); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Cast'); + }); + + it('renders the inactive cast SVG icon', () => { + renderCastButton({ isCasting: false }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute('fill')).toBe('currentColor'); + }); + + // ── Casting state ────────────────────────────────────────────────── + + it('renders stop casting label when casting', () => { + renderCastButton({ isCasting: true }); + expect(screen.getByRole('button', { name: 'Stop casting' })).toBeInTheDocument(); + }); + + it('shows stop casting title when casting', () => { + renderCastButton({ isCasting: true }); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Stop casting'); + }); + + it('renders the active cast SVG icon when casting', () => { + renderCastButton({ isCasting: true }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + // ── Icon differences between states ──────────────────────────────── + + it('renders different SVG path data for casting vs not casting', () => { + const { rerender } = render( + , + { wrapper: Wrapper } + ); + const notCastingPath = screen.getByRole('button').querySelector('svg path')?.getAttribute('d'); + + rerender(); + const castingPath = screen.getByRole('button').querySelector('svg path')?.getAttribute('d'); + + expect(notCastingPath).toBeTruthy(); + expect(castingPath).toBeTruthy(); + expect(notCastingPath).not.toBe(castingPath); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + renderCastButton({ onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); + renderCastButton({ disabled: true, onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // ── Disabled state ───────────────────────────────────────────────── + + it('disables the button when disabled prop is true', () => { + renderCastButton({ disabled: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies disabled opacity styling', () => { + renderCastButton({ disabled: true }); + expect(screen.getByRole('button').className).toContain('opacity-50'); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className', () => { + renderCastButton({ className: 'my-cast-class' }); + expect(screen.getByRole('button').className).toContain('my-cast-class'); + }); + + // ── Aria labels toggle ────────────────────────────────────────────── + + it('toggles aria-label on state change via rerender', () => { + const { rerender } = render( + , + { wrapper: Wrapper } + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Cast'); + + rerender(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Stop casting'); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom start cast label', () => { + renderCastButton({ + isCasting: false, + labels: { startCast: 'Streamen', stopCast: 'Streaming beenden' }, + }); + expect(screen.getByRole('button', { name: 'Streamen' })).toBeInTheDocument(); + }); + + it('uses custom stop cast label', () => { + renderCastButton({ + isCasting: true, + labels: { startCast: 'Streamen', stopCast: 'Streaming beenden' }, + }); + expect(screen.getByRole('button', { name: 'Streaming beenden' })).toBeInTheDocument(); + }); + + // ── CSS class ────────────────────────────────────────────────────── + + it('has the fp-cast-button class', () => { + renderCastButton(); + expect(screen.getByRole('button').className).toContain('fp-cast-button'); + }); +}); diff --git a/src/components/controls/FullscreenButton/FullscreenButton.test.tsx b/src/components/controls/FullscreenButton/FullscreenButton.test.tsx new file mode 100644 index 0000000..48daa12 --- /dev/null +++ b/src/components/controls/FullscreenButton/FullscreenButton.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FullscreenButton } from './FullscreenButton'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderFullscreenButton(props: Partial[0]> = {}) { + const defaults = { + isFullscreen: false, + onClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('FullscreenButton', () => { + // ── Enter fullscreen state ────────────────────────────────────────── + + it('renders enter fullscreen label when not fullscreen', () => { + renderFullscreenButton({ isFullscreen: false }); + expect(screen.getByRole('button', { name: 'Enter fullscreen' })).toBeInTheDocument(); + }); + + it('shows enter fullscreen title when not fullscreen', () => { + renderFullscreenButton({ isFullscreen: false }); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Enter fullscreen'); + }); + + it('renders enter fullscreen SVG icon', () => { + renderFullscreenButton({ isFullscreen: false }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeTruthy(); + const path = svg?.querySelector('path'); + expect(path?.getAttribute('d')).toContain('H5'); + }); + + // ── Exit fullscreen state ────────────────────────────────────────── + + it('renders exit fullscreen label when fullscreen', () => { + renderFullscreenButton({ isFullscreen: true }); + expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument(); + }); + + it('shows exit fullscreen title when fullscreen', () => { + renderFullscreenButton({ isFullscreen: true }); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Exit fullscreen'); + }); + + it('renders exit fullscreen SVG icon', () => { + renderFullscreenButton({ isFullscreen: true }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeTruthy(); + const path = svg?.querySelector('path'); + expect(path?.getAttribute('d')).toContain('v3'); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + renderFullscreenButton({ onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); + renderFullscreenButton({ disabled: true, onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // ── Disabled state ───────────────────────────────────────────────── + + it('disables the button when disabled prop is true', () => { + renderFullscreenButton({ disabled: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies disabled styling', () => { + renderFullscreenButton({ disabled: true }); + expect(screen.getByRole('button').className).toContain('opacity-50'); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the button', () => { + renderFullscreenButton({ className: 'my-fullscreen' }); + expect(screen.getByRole('button').className).toContain('my-fullscreen'); + }); + + // ── Aria labels toggle ────────────────────────────────────────────── + + it('toggles aria-label on state change via rerender', () => { + const { rerender } = render( + , + { wrapper: Wrapper } + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Enter fullscreen'); + + rerender(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Exit fullscreen'); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom enter fullscreen label', () => { + renderFullscreenButton({ + isFullscreen: false, + labels: { enterFullscreen: 'Vollbild', exitFullscreen: 'Vollbild beenden' }, + }); + expect(screen.getByRole('button', { name: 'Vollbild' })).toBeInTheDocument(); + }); + + it('uses custom exit fullscreen label', () => { + renderFullscreenButton({ + isFullscreen: true, + labels: { enterFullscreen: 'Vollbild', exitFullscreen: 'Vollbild beenden' }, + }); + expect(screen.getByRole('button', { name: 'Vollbild beenden' })).toBeInTheDocument(); + }); + + // ── CSS class ────────────────────────────────────────────────────── + + it('has the fp-fullscreen-button class', () => { + renderFullscreenButton(); + expect(screen.getByRole('button').className).toContain('fp-fullscreen-button'); + }); +}); diff --git a/src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx b/src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx new file mode 100644 index 0000000..15ab728 --- /dev/null +++ b/src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { NowPlayingIndicator } from './NowPlayingIndicator'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderIndicator(props: Partial[0]> = {}) { + const defaults = { + isPlaying: false, + }; + return render(, { wrapper: Wrapper }); +} + +describe('NowPlayingIndicator', () => { + // ── Playing state ─────────────────────────────────────────────────── + + it('renders with "Now playing" aria-label when playing', () => { + renderIndicator({ isPlaying: true }); + expect(screen.getByLabelText('Now playing')).toBeInTheDocument(); + }); + + it('renders bars without paused class when playing', () => { + renderIndicator({ isPlaying: true }); + const bars = screen.getByRole('img').querySelectorAll('span'); + bars.forEach((bar) => { + expect(bar.className).toContain('fp-equalizer-bar'); + expect(bar.className).not.toContain('fp-equalizer-paused'); + }); + }); + + // ── Paused state ─────────────────────────────────────────────────── + + it('renders with "Paused" aria-label when paused', () => { + renderIndicator({ isPlaying: false }); + expect(screen.getByLabelText('Paused')).toBeInTheDocument(); + }); + + it('renders bars with paused class when not playing', () => { + renderIndicator({ isPlaying: false }); + const bars = screen.getByRole('img').querySelectorAll('span'); + bars.forEach((bar) => { + expect(bar.className).toContain('fp-equalizer-paused'); + }); + }); + + // ── Default bars count ────────────────────────────────────────────── + + it('renders 4 bars by default', () => { + renderIndicator(); + const bars = screen.getByRole('img').querySelectorAll('span'); + expect(bars.length).toBe(4); + }); + + // ── Custom bars count ────────────────────────────────────────────── + + it('renders custom number of bars', () => { + renderIndicator({ bars: 6 }); + const barElements = screen.getByRole('img').querySelectorAll('span'); + expect(barElements.length).toBe(6); + }); + + it('renders 3 bars when specified', () => { + renderIndicator({ bars: 3 }); + const barElements = screen.getByRole('img').querySelectorAll('span'); + expect(barElements.length).toBe(3); + }); + + // ── Size variants ────────────────────────────────────────────────── + + it('applies small size class', () => { + renderIndicator({ size: 'sm' }); + const container = screen.getByRole('img'); + expect(container.className).toContain('h-3'); + }); + + it('applies medium size class by default', () => { + renderIndicator(); + const container = screen.getByRole('img'); + expect(container.className).toContain('h-4'); + }); + + it('applies large size class', () => { + renderIndicator({ size: 'lg' }); + const container = screen.getByRole('img'); + expect(container.className).toContain('h-5'); + }); + + it('applies correct bar width for small size', () => { + renderIndicator({ size: 'sm' }); + const bars = screen.getByRole('img').querySelectorAll('span'); + bars.forEach((bar) => expect(bar.className).toContain('w-0.5')); + }); + + it('applies correct bar width for medium size', () => { + renderIndicator({ size: 'md' }); + const bars = screen.getByRole('img').querySelectorAll('span'); + bars.forEach((bar) => expect(bar.className).toContain('w-1')); + }); + + it('applies correct bar width for large size', () => { + renderIndicator({ size: 'lg' }); + const bars = screen.getByRole('img').querySelectorAll('span'); + bars.forEach((bar) => expect(bar.className).toContain('w-1.5')); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the container', () => { + renderIndicator({ className: 'my-indicator' }); + expect(screen.getByRole('img').className).toContain('my-indicator'); + }); + + // ── Role ─────────────────────────────────────────────────────────── + + it('has role="img"', () => { + renderIndicator(); + expect(screen.getByRole('img')).toBeInTheDocument(); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom nowPlaying label', () => { + renderIndicator({ + isPlaying: true, + labels: { nowPlaying: 'Wird abgespielt', paused: 'Pausiert' }, + }); + expect(screen.getByLabelText('Wird abgespielt')).toBeInTheDocument(); + }); + + it('uses custom paused label', () => { + renderIndicator({ + isPlaying: false, + labels: { nowPlaying: 'Wird abgespielt', paused: 'Pausiert' }, + }); + expect(screen.getByLabelText('Pausiert')).toBeInTheDocument(); + }); + + // ── All bars have accent color ───────────────────────────────────── + + it('applies accent color class to all bars', () => { + renderIndicator({ isPlaying: true }); + const bars = screen.getByRole('img').querySelectorAll('span'); + bars.forEach((bar) => { + expect(bar.className).toContain('bg-[var(--fp-color-accent)]'); + }); + }); +}); diff --git a/src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx b/src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx new file mode 100644 index 0000000..f1dad98 --- /dev/null +++ b/src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PictureInPictureButton } from './PictureInPictureButton'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderPiPButton(props: Partial[0]> = {}) { + const defaults = { + isPictureInPicture: false, + onClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('PictureInPictureButton', () => { + // ── Enter PiP state ───────────────────────────────────────────────── + + it('renders enter PiP label when not in PiP', () => { + renderPiPButton({ isPictureInPicture: false }); + expect(screen.getByRole('button', { name: 'Enter picture-in-picture' })).toBeInTheDocument(); + }); + + it('shows enter PiP title when not in PiP', () => { + renderPiPButton({ isPictureInPicture: false }); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Enter picture-in-picture'); + }); + + it('renders the enter PiP icon with two rects', () => { + renderPiPButton({ isPictureInPicture: false }); + const svg = screen.getByRole('button').querySelector('svg'); + const rects = svg?.querySelectorAll('rect'); + expect(rects?.length).toBe(2); + }); + + // ── Exit PiP state ───────────────────────────────────────────────── + + it('renders exit PiP label when in PiP', () => { + renderPiPButton({ isPictureInPicture: true }); + expect(screen.getByRole('button', { name: 'Exit picture-in-picture' })).toBeInTheDocument(); + }); + + it('shows exit PiP title when in PiP', () => { + renderPiPButton({ isPictureInPicture: true }); + expect(screen.getByRole('button')).toHaveAttribute('title', 'Exit picture-in-picture'); + }); + + it('renders exit PiP icon with one rect and a path', () => { + renderPiPButton({ isPictureInPicture: true }); + const svg = screen.getByRole('button').querySelector('svg'); + const rects = svg?.querySelectorAll('rect'); + const paths = svg?.querySelectorAll('path'); + expect(rects?.length).toBe(1); + expect(paths?.length).toBe(1); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + renderPiPButton({ onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); + renderPiPButton({ disabled: true, onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // ── Disabled state ───────────────────────────────────────────────── + + it('disables the button when disabled prop is true', () => { + renderPiPButton({ disabled: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies disabled opacity styling', () => { + renderPiPButton({ disabled: true }); + expect(screen.getByRole('button').className).toContain('opacity-50'); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className', () => { + renderPiPButton({ className: 'my-pip-class' }); + expect(screen.getByRole('button').className).toContain('my-pip-class'); + }); + + // ── Aria labels toggle ────────────────────────────────────────────── + + it('toggles aria-label on state change via rerender', () => { + const { rerender } = render( + , + { wrapper: Wrapper } + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Enter picture-in-picture'); + + rerender(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Exit picture-in-picture'); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom enter PiP label', () => { + renderPiPButton({ + isPictureInPicture: false, + labels: { enterPictureInPicture: 'Bild-in-Bild starten', exitPictureInPicture: 'Bild-in-Bild beenden' }, + }); + expect(screen.getByRole('button', { name: 'Bild-in-Bild starten' })).toBeInTheDocument(); + }); + + it('uses custom exit PiP label', () => { + renderPiPButton({ + isPictureInPicture: true, + labels: { enterPictureInPicture: 'Bild-in-Bild starten', exitPictureInPicture: 'Bild-in-Bild beenden' }, + }); + expect(screen.getByRole('button', { name: 'Bild-in-Bild beenden' })).toBeInTheDocument(); + }); + + // ── CSS class ────────────────────────────────────────────────────── + + it('has the fp-pip-button class', () => { + renderPiPButton(); + expect(screen.getByRole('button').className).toContain('fp-pip-button'); + }); + + // ── Always renders (no isSupported hide logic in this component) ─── + + it('always renders the button', () => { + renderPiPButton(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/src/components/controls/PlayButton/PlayButton.test.tsx b/src/components/controls/PlayButton/PlayButton.test.tsx new file mode 100644 index 0000000..d2d96b3 --- /dev/null +++ b/src/components/controls/PlayButton/PlayButton.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PlayButton } from './PlayButton'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderPlayButton(props: Partial[0]> = {}) { + const defaults = { + isPlaying: false, + onClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('PlayButton', () => { + // ── Play state ────────────────────────────────────────────────────── + + it('renders with play aria-label when not playing', () => { + renderPlayButton({ isPlaying: false }); + expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument(); + }); + + it('renders play icon paths when not playing', () => { + renderPlayButton({ isPlaying: false }); + const svgs = screen.getByRole('button').querySelectorAll('svg'); + // The morphing SVG should be present (not the loading one) + expect(svgs.length).toBe(1); + const paths = svgs[0].querySelectorAll('path'); + expect(paths.length).toBe(2); + }); + + // ── Pause state ───────────────────────────────────────────────────── + + it('renders with pause aria-label when playing', () => { + renderPlayButton({ isPlaying: true }); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + }); + + it('shows pause SVG path with full opacity when playing', () => { + renderPlayButton({ isPlaying: true }); + const btn = screen.getByRole('button'); + const paths = btn.querySelectorAll('svg path'); + // Second path is the pause icon; when playing it should have opacity-100 + const pausePath = paths[1]; + expect(pausePath.getAttribute('class')).toContain('opacity-100'); + }); + + it('shows play SVG path with zero opacity when playing', () => { + renderPlayButton({ isPlaying: true }); + const btn = screen.getByRole('button'); + const paths = btn.querySelectorAll('svg path'); + const playPath = paths[0]; + expect(playPath.getAttribute('class')).toContain('opacity-0'); + }); + + // ── Loading state ─────────────────────────────────────────────────── + + it('shows loading spinner when isLoading is true', () => { + renderPlayButton({ isLoading: true }); + const btn = screen.getByRole('button'); + const svg = btn.querySelector('svg'); + // Loading spinner has a circle and path, not the morphing play/pause paths + expect(svg?.querySelector('circle')).toBeTruthy(); + expect(svg?.querySelector('path')).toBeTruthy(); + }); + + it('disables the button when isLoading is true', () => { + renderPlayButton({ isLoading: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('does not call onClick when loading', () => { + const onClick = vi.fn(); + renderPlayButton({ isLoading: true, onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // ── Click handler ─────────────────────────────────────────────────── + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + renderPlayButton({ onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); + renderPlayButton({ disabled: true, onClick }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // ── Disabled state ────────────────────────────────────────────────── + + it('disables the button when disabled prop is true', () => { + renderPlayButton({ disabled: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies disabled styling', () => { + renderPlayButton({ disabled: true }); + expect(screen.getByRole('button').className).toContain('opacity-50'); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom play label', () => { + renderPlayButton({ + isPlaying: false, + labels: { play: 'Abspielen', pause: 'Stopp' }, + }); + expect(screen.getByRole('button', { name: 'Abspielen' })).toBeInTheDocument(); + }); + + it('uses custom pause label', () => { + renderPlayButton({ + isPlaying: true, + labels: { play: 'Abspielen', pause: 'Stopp' }, + }); + expect(screen.getByRole('button', { name: 'Stopp' })).toBeInTheDocument(); + }); + + // ── Size variants ────────────────────────────────────────────────── + + it('applies small size class', () => { + renderPlayButton({ size: 'sm' }); + expect(screen.getByRole('button').className).toContain('w-8'); + expect(screen.getByRole('button').className).toContain('h-8'); + }); + + it('applies medium size class by default', () => { + renderPlayButton(); + expect(screen.getByRole('button').className).toContain('w-12'); + expect(screen.getByRole('button').className).toContain('h-12'); + }); + + it('applies large size class', () => { + renderPlayButton({ size: 'lg' }); + expect(screen.getByRole('button').className).toContain('w-16'); + expect(screen.getByRole('button').className).toContain('h-16'); + }); + + // ── className passthrough ────────────────────────────────────────── + + it('passes custom className to the button', () => { + renderPlayButton({ className: 'my-custom-class' }); + expect(screen.getByRole('button').className).toContain('my-custom-class'); + }); + + // ── Button type ──────────────────────────────────────────────────── + + it('renders as a button element with type="button"', () => { + renderPlayButton(); + const btn = screen.getByRole('button'); + expect(btn.tagName).toBe('BUTTON'); + expect(btn.getAttribute('type')).toBe('button'); + }); + + // ── Glow effect ──────────────────────────────────────────────────── + + it('has a glow effect span element', () => { + renderPlayButton(); + const btn = screen.getByRole('button'); + const glowSpan = btn.querySelector('span'); + expect(glowSpan).toBeTruthy(); + }); + + // ── Icon sizing with button size ─────────────────────────────────── + + it('applies correct icon size for small button', () => { + renderPlayButton({ size: 'sm' }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg?.getAttribute('class')).toContain('w-4'); + expect(svg?.getAttribute('class')).toContain('h-4'); + }); + + it('applies correct icon size for large button', () => { + renderPlayButton({ size: 'lg' }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg?.getAttribute('class')).toContain('w-8'); + expect(svg?.getAttribute('class')).toContain('h-8'); + }); +}); diff --git a/src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx b/src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx new file mode 100644 index 0000000..ec7f70c --- /dev/null +++ b/src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx @@ -0,0 +1,163 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PlaybackSpeed } from './PlaybackSpeed'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderPlaybackSpeed(props: Partial[0]> = {}) { + const defaults = { + speed: 1, + onSpeedChange: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('PlaybackSpeed', () => { + // ── Current speed display ─────────────────────────────────────────── + + it('renders the current speed as 1x', () => { + renderPlaybackSpeed({ speed: 1 }); + expect(screen.getByText('1x')).toBeInTheDocument(); + }); + + it('renders speed 0.5 as 0.5x', () => { + renderPlaybackSpeed({ speed: 0.5 }); + expect(screen.getByText('0.5x')).toBeInTheDocument(); + }); + + it('renders speed 2 as 2x', () => { + renderPlaybackSpeed({ speed: 2 }); + expect(screen.getByText('2x')).toBeInTheDocument(); + }); + + it('renders speed 1.5 as 1.5x', () => { + renderPlaybackSpeed({ speed: 1.5 }); + expect(screen.getByText('1.5x')).toBeInTheDocument(); + }); + + // ── Dropdown menu ─────────────────────────────────────────────────── + + it('does not show dropdown menu initially', () => { + renderPlaybackSpeed(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('opens dropdown menu on click', () => { + renderPlaybackSpeed(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('shows all default speed options in the menu', () => { + renderPlaybackSpeed(); + fireEvent.click(screen.getByRole('button')); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(6); // [0.5, 0.75, 1, 1.25, 1.5, 2] + }); + + it('marks the current speed as selected', () => { + renderPlaybackSpeed({ speed: 1.5 }); + fireEvent.click(screen.getByRole('button')); + const selectedOption = screen.getByRole('option', { selected: true }); + expect(selectedOption).toHaveTextContent('1.5x'); + }); + + it('closes the dropdown after selecting a speed', () => { + const onSpeedChange = vi.fn(); + renderPlaybackSpeed({ speed: 1, onSpeedChange }); + fireEvent.click(screen.getByRole('button')); + const options = screen.getAllByRole('option'); + // Click the 2x option (last one) + fireEvent.click(options[options.length - 1]); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + // ── Speed change callback ─────────────────────────────────────────── + + it('calls onSpeedChange when a speed is selected', () => { + const onSpeedChange = vi.fn(); + renderPlaybackSpeed({ speed: 1, onSpeedChange }); + fireEvent.click(screen.getByRole('button')); + const options = screen.getAllByRole('option'); + // Click 0.5x (first option) + fireEvent.click(options[0]); + expect(onSpeedChange).toHaveBeenCalledWith(0.5); + }); + + it('calls onSpeedChange with 2 when 2x is selected', () => { + const onSpeedChange = vi.fn(); + renderPlaybackSpeed({ speed: 1, onSpeedChange }); + fireEvent.click(screen.getByRole('button')); + const options = screen.getAllByRole('option'); + fireEvent.click(options[options.length - 1]); + expect(onSpeedChange).toHaveBeenCalledWith(2); + }); + + // ── Custom speeds ────────────────────────────────────────────────── + + it('renders custom speed options', () => { + renderPlaybackSpeed({ speeds: [0.25, 0.5, 1, 3] }); + fireEvent.click(screen.getByRole('button')); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(4); + expect(options[0]).toHaveTextContent('0.25x'); + expect(options[3]).toHaveTextContent('3x'); + }); + + // ── Disabled state ───────────────────────────────────────────────── + + it('disables the button when disabled is true', () => { + renderPlaybackSpeed({ disabled: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies disabled styling', () => { + renderPlaybackSpeed({ disabled: true }); + expect(screen.getByRole('button').className).toContain('opacity-50'); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes className to the container', () => { + const { container } = renderPlaybackSpeed({ className: 'custom-speed' }); + expect(container.firstElementChild?.className).toContain('custom-speed'); + }); + + // ── Accessibility ────────────────────────────────────────────────── + + it('has aria-label with speed info', () => { + renderPlaybackSpeed({ speed: 1.5 }); + expect(screen.getByRole('button', { name: /playback speed.*1\.5x/i })).toBeInTheDocument(); + }); + + it('has aria-haspopup on the trigger button', () => { + renderPlaybackSpeed(); + expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'listbox'); + }); + + it('has aria-expanded false when menu is closed', () => { + renderPlaybackSpeed(); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('has aria-expanded true when menu is open', () => { + renderPlaybackSpeed(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument(); + }); + + // ── Toggle behavior ──────────────────────────────────────────────── + + it('toggles dropdown open and closed on repeated clicks', () => { + renderPlaybackSpeed(); + const trigger = screen.getByRole('button'); + fireEvent.click(trigger); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + fireEvent.click(trigger); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/controls/ProgressBar/ProgressBar.test.tsx b/src/components/controls/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 0000000..7a89985 --- /dev/null +++ b/src/components/controls/ProgressBar/ProgressBar.test.tsx @@ -0,0 +1,384 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProgressBar } from './ProgressBar'; +import { LabelsProvider } from '@/context/LabelsContext'; +import { createMockChapters, createMockMarkers } from '@/test/helpers'; +import type { ReactNode } from 'react'; +import type { Chapter } from '@/types/player'; +import type { TimelineMarker } from '@/types/markers'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderProgressBar( + props: Partial[0]> = {} +) { + const defaults = { + currentTime: 60, + duration: 300, + }; + return render(, { wrapper: Wrapper }); +} + +// Mock getBoundingClientRect for position calculations +function mockSliderRect(el: Element) { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 500, + top: 0, + bottom: 20, + width: 500, + height: 20, + x: 0, + y: 0, + toJSON: () => {}, + }); +} + +describe('ProgressBar', () => { + // ── Basic rendering ─────────────────────────────────────────────── + + it('renders the slider element', () => { + renderProgressBar(); + expect(screen.getByRole('slider')).toBeInTheDocument(); + }); + + it('has correct aria attributes', () => { + renderProgressBar({ currentTime: 60, duration: 300 }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-valuemin')).toBe('0'); + expect(slider.getAttribute('aria-valuemax')).toBe('300'); + expect(slider.getAttribute('aria-valuenow')).toBe('60'); + }); + + it('has correct aria-valuetext', () => { + renderProgressBar({ currentTime: 65, duration: 300 }); + const slider = screen.getByRole('slider'); + // 65 seconds = 1:05, 300 seconds = 5:00 + expect(slider.getAttribute('aria-valuetext')).toBe('1:05 of 5:00'); + }); + + it('has aria-label from labels', () => { + renderProgressBar(); + expect(screen.getByRole('slider')).toHaveAttribute('aria-label', 'Seek slider'); + }); + + it('is focusable by default', () => { + renderProgressBar(); + expect(screen.getByRole('slider').getAttribute('tabindex')).toBe('0'); + }); + + it('is not focusable when disabled', () => { + renderProgressBar({ disabled: true }); + expect(screen.getByRole('slider').getAttribute('tabindex')).toBe('-1'); + }); + + // ── Progress display ────────────────────────────────────────────── + + it('shows current progress as percentage width', () => { + const { container } = renderProgressBar({ currentTime: 150, duration: 300 }); + // 150/300 = 50% + const progressBar = container.querySelectorAll('.rounded-full')[2]; // current progress div + expect((progressBar as HTMLElement).style.width).toBe('50%'); + }); + + it('shows 0% progress when duration is 0', () => { + const { container } = renderProgressBar({ currentTime: 0, duration: 0 }); + // Should not crash and show 0% + expect(screen.getByRole('slider')).toBeInTheDocument(); + }); + + // ── Buffered indicator ──────────────────────────────────────────── + + it('shows buffered progress', () => { + const { container } = renderProgressBar({ buffered: 200, duration: 300 }); + // 200/300 = ~66.67% + const bufferedBar = container.querySelector('.bg-\\[var\\(--fp-progress-buffer\\)\\]'); + expect(bufferedBar).toBeInTheDocument(); + expect((bufferedBar as HTMLElement).style.width).toBe('66.66666666666666%'); + }); + + it('shows 0% buffered by default', () => { + const { container } = renderProgressBar(); + const bufferedBar = container.querySelector('.bg-\\[var\\(--fp-progress-buffer\\)\\]'); + expect((bufferedBar as HTMLElement).style.width).toBe('0%'); + }); + + // ── Seeking via click ───────────────────────────────────────────── + + it('calls onSeek when clicked', () => { + const onSeek = vi.fn(); + renderProgressBar({ onSeek }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + // Click at position 250/500 = 50% of 300 = 150 + fireEvent.mouseDown(slider, { clientX: 250 }); + expect(onSeek).toHaveBeenCalledWith(150); + }); + + it('calls onSeekStart when mouse down', () => { + const onSeekStart = vi.fn(); + renderProgressBar({ onSeekStart }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseDown(slider, { clientX: 100 }); + expect(onSeekStart).toHaveBeenCalledTimes(1); + }); + + it('does not seek when disabled', () => { + const onSeek = vi.fn(); + const onSeekStart = vi.fn(); + renderProgressBar({ onSeek, onSeekStart, disabled: true }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseDown(slider, { clientX: 250 }); + expect(onSeek).not.toHaveBeenCalled(); + expect(onSeekStart).not.toHaveBeenCalled(); + }); + + // ── Dragging ────────────────────────────────────────────────────── + + it('calls onSeek during drag (mouseMove while dragging)', () => { + const onSeek = vi.fn(); + renderProgressBar({ onSeek }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + + // Start drag + fireEvent.mouseDown(slider, { clientX: 100 }); + onSeek.mockClear(); + + // Move while hovering the slider element + fireEvent.mouseMove(slider, { clientX: 200 }); + expect(onSeek).toHaveBeenCalled(); + }); + + it('calls onSeekEnd on global mouseup after drag', () => { + const onSeekEnd = vi.fn(); + renderProgressBar({ onSeekEnd }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + + // Start drag + fireEvent.mouseDown(slider, { clientX: 100 }); + // End drag (global mouseup) + fireEvent.mouseUp(document); + expect(onSeekEnd).toHaveBeenCalledTimes(1); + }); + + // ── Touch events ────────────────────────────────────────────────── + + it('calls onSeek on touch start', () => { + const onSeek = vi.fn(); + renderProgressBar({ onSeek }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.touchStart(slider, { touches: [{ clientX: 250 }] }); + expect(onSeek).toHaveBeenCalled(); + }); + + it('does not seek on touch when disabled', () => { + const onSeek = vi.fn(); + renderProgressBar({ onSeek, disabled: true }); + const slider = screen.getByRole('slider'); + fireEvent.touchStart(slider, { touches: [{ clientX: 250 }] }); + expect(onSeek).not.toHaveBeenCalled(); + }); + + it('calls onSeekEnd on touch end', () => { + const onSeekEnd = vi.fn(); + renderProgressBar({ onSeekEnd }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.touchStart(slider, { touches: [{ clientX: 100 }] }); + fireEvent.touchEnd(slider); + expect(onSeekEnd).toHaveBeenCalledTimes(1); + }); + + // ── Keyboard accessibility ──────────────────────────────────────── + + it('seeks backward 5 seconds on ArrowLeft', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 60, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft' }); + expect(onSeek).toHaveBeenCalledWith(55); + }); + + it('seeks forward 5 seconds on ArrowRight', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 60, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' }); + expect(onSeek).toHaveBeenCalledWith(65); + }); + + it('seeks backward 10 seconds on Shift+ArrowLeft', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 60, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft', shiftKey: true }); + expect(onSeek).toHaveBeenCalledWith(50); + }); + + it('seeks forward 10 seconds on Shift+ArrowRight', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 60, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight', shiftKey: true }); + expect(onSeek).toHaveBeenCalledWith(70); + }); + + it('seeks to start on Home key', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 60, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'Home' }); + expect(onSeek).toHaveBeenCalledWith(0); + }); + + it('seeks to end on End key', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 60, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'End' }); + expect(onSeek).toHaveBeenCalledWith(300); + }); + + it('does not go below 0 on ArrowLeft', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 2, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft' }); + expect(onSeek).toHaveBeenCalledWith(0); + }); + + it('does not go above duration on ArrowRight', () => { + const onSeek = vi.fn(); + renderProgressBar({ currentTime: 298, duration: 300, onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' }); + expect(onSeek).toHaveBeenCalledWith(300); + }); + + it('ignores keyboard events when disabled', () => { + const onSeek = vi.fn(); + renderProgressBar({ onSeek, disabled: true }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowLeft' }); + expect(onSeek).not.toHaveBeenCalled(); + }); + + it('ignores unrelated keys', () => { + const onSeek = vi.fn(); + renderProgressBar({ onSeek }); + fireEvent.keyDown(screen.getByRole('slider'), { key: 'a' }); + expect(onSeek).not.toHaveBeenCalled(); + }); + + // ── Chapter markers ─────────────────────────────────────────────── + + it('renders chapter markers', () => { + const chapters = createMockChapters(); + const { container } = renderProgressBar({ chapters, duration: 180 }); + // Chapter markers at startTime 0, 30, 120 => 3 markers + const markers = container.querySelectorAll('.w-0\\.5'); + expect(markers.length).toBe(3); + }); + + it('positions chapter markers correctly', () => { + const chapters: Chapter[] = [ + { id: 'ch-1', title: 'Intro', startTime: 0, endTime: 60 }, + { id: 'ch-2', title: 'Middle', startTime: 60, endTime: 120 }, + ]; + const { container } = renderProgressBar({ chapters, duration: 120 }); + const markerElements = container.querySelectorAll('.w-0\\.5'); + expect((markerElements[0] as HTMLElement).style.left).toBe('0%'); + expect((markerElements[1] as HTMLElement).style.left).toBe('50%'); + }); + + // ── Timeline markers ────────────────────────────────────────────── + + it('renders timeline markers', () => { + const markers = createMockMarkers(); + const { container } = renderProgressBar({ markers, duration: 180 }); + // 3 marker dots + const markerDots = container.querySelectorAll('.rounded-full.-translate-y-1\\/2'); + expect(markerDots.length).toBeGreaterThanOrEqual(3); + }); + + it('applies custom marker color', () => { + const markers: TimelineMarker[] = [ + { id: 'm-1', time: 60, title: 'Marker', color: '#ff0000' }, + ]; + const { container } = renderProgressBar({ markers, duration: 300 }); + const markerDot = container.querySelector('.-translate-x-1\\/2.-translate-y-1\\/2'); + expect((markerDot as HTMLElement)?.style.backgroundColor).toBe('rgb(255, 0, 0)'); + }); + + // ── Hover / Tooltip ─────────────────────────────────────────────── + + it('shows tooltip on hover when showTooltip is true', () => { + const { container } = renderProgressBar({ showTooltip: true }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseEnter(slider); + fireEvent.mouseMove(slider, { clientX: 250 }); + // Tooltip should appear with time + const tooltip = container.querySelector('.pointer-events-none'); + expect(tooltip).toBeInTheDocument(); + }); + + it('does not show tooltip when showTooltip is false', () => { + const { container } = renderProgressBar({ showTooltip: false }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseEnter(slider); + fireEvent.mouseMove(slider, { clientX: 250 }); + const tooltip = container.querySelector('.pointer-events-none.whitespace-nowrap'); + expect(tooltip).toBeNull(); + }); + + it('does not show tooltip when disabled', () => { + const { container } = renderProgressBar({ disabled: true, showTooltip: true }); + const slider = screen.getByRole('slider'); + fireEvent.mouseEnter(slider); + fireEvent.mouseMove(slider, { clientX: 250 }); + const tooltip = container.querySelector('.pointer-events-none.whitespace-nowrap'); + expect(tooltip).toBeNull(); + }); + + it('hides tooltip on mouse leave', () => { + renderProgressBar({ showTooltip: true }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseEnter(slider); + fireEvent.mouseMove(slider, { clientX: 250 }); + fireEvent.mouseLeave(slider); + // hoverPosition becomes null + const tooltip = screen.getByRole('slider').parentElement?.querySelector('.pointer-events-none.whitespace-nowrap'); + expect(tooltip).toBeNull(); + }); + + // ── Disabled state ──────────────────────────────────────────────── + + it('applies disabled styling', () => { + renderProgressBar({ disabled: true }); + expect(screen.getByRole('slider').className).toContain('cursor-not-allowed'); + expect(screen.getByRole('slider').className).toContain('opacity-50'); + }); + + it('hides drag handle when disabled', () => { + const { container } = renderProgressBar({ disabled: true }); + const handle = container.querySelector('.shadow-md'); + expect(handle?.className).toContain('hidden'); + }); + + // ── className passthrough ───────────────────────────────────────── + + it('applies custom className', () => { + renderProgressBar({ className: 'my-progress' }); + expect(screen.getByRole('slider').className).toContain('my-progress'); + }); + + // ── Custom labels ───────────────────────────────────────────────── + + it('uses custom seek slider label', () => { + renderProgressBar({ labels: { seekSlider: 'Zeitleiste' } }); + expect(screen.getByRole('slider', { name: 'Zeitleiste' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/controls/SkipButtons/SkipButtons.test.tsx b/src/components/controls/SkipButtons/SkipButtons.test.tsx new file mode 100644 index 0000000..4769a14 --- /dev/null +++ b/src/components/controls/SkipButtons/SkipButtons.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SkipButtons, SkipButton } from './SkipButtons'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderSkipButtons(props: Partial[0]> = {}) { + const defaults = { + onSkipForward: vi.fn(), + onSkipBackward: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('SkipButtons', () => { + // ── Rendering both buttons ────────────────────────────────────────── + + it('renders both skip forward and skip backward buttons', () => { + renderSkipButtons(); + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + }); + + it('displays default seconds labels (10 and 30)', () => { + renderSkipButtons(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('30')).toBeInTheDocument(); + }); + + it('has correct aria-labels for default seconds', () => { + renderSkipButtons(); + expect(screen.getByRole('button', { name: /skip backward 10 seconds/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /skip forward 30 seconds/i })).toBeInTheDocument(); + }); + + // ── Click handlers ───────────────────────────────────────────────── + + it('calls onSkipBackward when backward button is clicked', () => { + const onSkipBackward = vi.fn(); + renderSkipButtons({ onSkipBackward }); + fireEvent.click(screen.getByRole('button', { name: /skip backward/i })); + expect(onSkipBackward).toHaveBeenCalledTimes(1); + }); + + it('calls onSkipForward when forward button is clicked', () => { + const onSkipForward = vi.fn(); + renderSkipButtons({ onSkipForward }); + fireEvent.click(screen.getByRole('button', { name: /skip forward/i })); + expect(onSkipForward).toHaveBeenCalledTimes(1); + }); + + // ── Custom seconds ───────────────────────────────────────────────── + + it('displays custom forward seconds', () => { + renderSkipButtons({ forwardSeconds: 15 }); + expect(screen.getByText('15')).toBeInTheDocument(); + }); + + it('displays custom backward seconds', () => { + renderSkipButtons({ backwardSeconds: 5 }); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('updates aria-labels with custom seconds', () => { + renderSkipButtons({ forwardSeconds: 15, backwardSeconds: 5 }); + expect(screen.getByRole('button', { name: /skip backward 5 seconds/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /skip forward 15 seconds/i })).toBeInTheDocument(); + }); + + // ── Disabled state ───────────────────────────────────────────────── + + it('disables both buttons when disabled', () => { + renderSkipButtons({ disabled: true }); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => expect(btn).toBeDisabled()); + }); + + it('does not call handlers when disabled', () => { + const onSkipForward = vi.fn(); + const onSkipBackward = vi.fn(); + renderSkipButtons({ disabled: true, onSkipForward, onSkipBackward }); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => fireEvent.click(btn)); + expect(onSkipForward).not.toHaveBeenCalled(); + expect(onSkipBackward).not.toHaveBeenCalled(); + }); + + it('applies disabled opacity styling', () => { + renderSkipButtons({ disabled: true }); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => expect(btn.className).toContain('opacity-50')); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes className to the container div', () => { + const { container } = renderSkipButtons({ className: 'skip-custom' }); + expect(container.firstElementChild?.className).toContain('skip-custom'); + }); + + // ── Button type ──────────────────────────────────────────────────── + + it('renders buttons with type="button"', () => { + renderSkipButtons(); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => expect(btn.getAttribute('type')).toBe('button')); + }); +}); + +describe('SkipButton (individual)', () => { + function renderSingle(props: Partial[0]> = {}) { + const defaults = { + direction: 'forward' as const, + onClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); + } + + it('renders forward button with default 30 seconds', () => { + renderSingle({ direction: 'forward' }); + expect(screen.getByText('30')).toBeInTheDocument(); + }); + + it('renders backward button with default 10 seconds', () => { + renderSingle({ direction: 'backward' }); + expect(screen.getByText('10')).toBeInTheDocument(); + }); + + it('renders forward button with custom seconds', () => { + renderSingle({ direction: 'forward', seconds: 15 }); + expect(screen.getByText('15')).toBeInTheDocument(); + }); + + it('renders backward button with custom seconds', () => { + renderSingle({ direction: 'backward', seconds: 5 }); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('applies small size classes', () => { + renderSingle({ size: 'sm' }); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('h-9'); + }); + + it('applies medium size classes by default', () => { + renderSingle(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('h-12'); + }); + + it('renders SVG icon', () => { + renderSingle({ direction: 'forward' }); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeTruthy(); + }); +}); diff --git a/src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx b/src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx new file mode 100644 index 0000000..cb48c86 --- /dev/null +++ b/src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx @@ -0,0 +1,252 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SubtitleSelector } from './SubtitleSelector'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; +import type { Subtitle } from '@/types/video'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function createSubtitles(): Subtitle[] { + return [ + { id: 'en', label: 'English', language: 'en', src: '/subs/en.vtt' }, + { id: 'de', label: 'Deutsch', language: 'de', src: '/subs/de.vtt' }, + { id: 'fr', label: 'Fran\u00e7ais', language: 'fr', src: '/subs/fr.vtt' }, + ]; +} + +function renderSubtitleSelector( + props: Partial[0]> = {} +) { + const defaults = { + currentSubtitle: null as string | null, + subtitles: createSubtitles(), + onSubtitleChange: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('SubtitleSelector', () => { + // ── Basic rendering ─────────────────────────────────────────────── + + it('renders the CC toggle button', () => { + renderSubtitleSelector(); + expect(screen.getByRole('button', { name: 'Subtitles' })).toBeInTheDocument(); + }); + + it('renders nothing when subtitles array is empty', () => { + const { container } = renderSubtitleSelector({ subtitles: [] }); + expect(container.firstChild).toBeNull(); + }); + + it('applies custom className', () => { + const { container } = renderSubtitleSelector({ className: 'my-selector' }); + expect(container.querySelector('.fp-subtitle-selector')?.className).toContain('my-selector'); + }); + + // ── Dropdown toggle ─────────────────────────────────────────────── + + it('does not show dropdown initially', () => { + renderSubtitleSelector(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('shows dropdown when toggle button is clicked', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('hides dropdown when toggle button is clicked again', () => { + renderSubtitleSelector(); + const btn = screen.getByRole('button', { name: 'Subtitles' }); + fireEvent.click(btn); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + fireEvent.click(btn); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('sets aria-expanded to true when open', () => { + renderSubtitleSelector(); + const btn = screen.getByRole('button', { name: 'Subtitles' }); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + fireEvent.click(btn); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + it('has aria-haspopup listbox', () => { + renderSubtitleSelector(); + const btn = screen.getByRole('button', { name: 'Subtitles' }); + expect(btn.getAttribute('aria-haspopup')).toBe('listbox'); + }); + + // ── Dropdown content ────────────────────────────────────────────── + + it('shows "Off" option in dropdown', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + expect(screen.getByText('Off')).toBeInTheDocument(); + }); + + it('shows all subtitle options in dropdown', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByText('Deutsch')).toBeInTheDocument(); + expect(screen.getByText('Fran\u00e7ais')).toBeInTheDocument(); + }); + + it('marks "Off" as selected when currentSubtitle is null', () => { + renderSubtitleSelector({ currentSubtitle: null }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + const offOption = screen.getByRole('option', { name: /Off/ }); + expect(offOption.getAttribute('aria-selected')).toBe('true'); + }); + + it('marks current subtitle as selected', () => { + renderSubtitleSelector({ currentSubtitle: 'de' }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + const deOption = screen.getByRole('option', { name: /Deutsch/ }); + expect(deOption.getAttribute('aria-selected')).toBe('true'); + }); + + it('shows checkmark on selected subtitle', () => { + renderSubtitleSelector({ currentSubtitle: 'en' }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + const enOption = screen.getByRole('option', { name: /English/ }); + expect(enOption.querySelector('svg')).toBeTruthy(); + }); + + it('does not show checkmark on unselected subtitle', () => { + renderSubtitleSelector({ currentSubtitle: 'en' }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + const deOption = screen.getByRole('option', { name: /Deutsch/ }); + expect(deOption.querySelector('svg')).toBeNull(); + }); + + // ── Selection ───────────────────────────────────────────────────── + + it('calls onSubtitleChange with null when "Off" is selected', () => { + const onChange = vi.fn(); + renderSubtitleSelector({ currentSubtitle: 'en', onSubtitleChange: onChange }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + fireEvent.click(screen.getByText('Off')); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('calls onSubtitleChange with subtitle id when subtitle is selected', () => { + const onChange = vi.fn(); + renderSubtitleSelector({ onSubtitleChange: onChange }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + fireEvent.click(screen.getByText('English')); + expect(onChange).toHaveBeenCalledWith('en'); + }); + + it('closes dropdown after selecting a subtitle', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + fireEvent.click(screen.getByText('Deutsch')); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('closes dropdown after selecting "Off"', () => { + renderSubtitleSelector({ currentSubtitle: 'en' }); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + fireEvent.click(screen.getByText('Off')); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + // ── Close behaviors ─────────────────────────────────────────────── + + it('closes dropdown when clicking outside', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('closes dropdown when Escape key is pressed', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + // ── Disabled state ──────────────────────────────────────────────── + + it('disables the toggle button when disabled', () => { + renderSubtitleSelector({ disabled: true }); + expect(screen.getByRole('button', { name: 'Subtitles' })).toBeDisabled(); + }); + + it('applies disabled styling', () => { + renderSubtitleSelector({ disabled: true }); + expect(screen.getByRole('button', { name: 'Subtitles' }).className).toContain('opacity-50'); + }); + + // ── Current subtitle label display ──────────────────────────────── + + it('shows current subtitle label next to CC icon when subtitle is active', () => { + renderSubtitleSelector({ currentSubtitle: 'en' }); + // The label is shown in a span with hidden sm:inline + const btn = screen.getByRole('button', { name: 'Subtitles' }); + expect(btn.textContent).toContain('English'); + }); + + it('does not show subtitle label when currentSubtitle is null', () => { + renderSubtitleSelector({ currentSubtitle: null }); + const btn = screen.getByRole('button', { name: 'Subtitles' }); + // When null, the subtitle label span is not rendered + expect(btn.textContent).not.toContain('English'); + expect(btn.textContent).not.toContain('Off'); + }); + + // ── Custom labels ───────────────────────────────────────────────── + + it('uses custom labels', () => { + renderSubtitleSelector({ + labels: { + subtitles: 'Untertitel', + subtitleOptions: 'Untertitel-Optionen', + subtitlesOff: 'Aus', + }, + }); + expect(screen.getByRole('button', { name: 'Untertitel' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Untertitel' })); + expect(screen.getByText('Aus')).toBeInTheDocument(); + }); + + it('uses custom subtitle options aria-label on listbox', () => { + renderSubtitleSelector({ + labels: { + subtitles: 'Untertitel', + subtitleOptions: 'Untertitel-Optionen', + subtitlesOff: 'Aus', + }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Untertitel' })); + expect(screen.getByRole('listbox', { name: 'Untertitel-Optionen' })).toBeInTheDocument(); + }); + + // ── Dropdown aria ───────────────────────────────────────────────── + + it('dropdown has correct listbox aria-label', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + expect(screen.getByRole('listbox', { name: 'Subtitle options' })).toBeInTheDocument(); + }); + + it('all options have role="option"', () => { + renderSubtitleSelector(); + fireEvent.click(screen.getByRole('button', { name: 'Subtitles' })); + // Off + 3 subtitles = 4 options + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(4); + }); +}); diff --git a/src/components/controls/TimeDisplay/TimeDisplay.test.tsx b/src/components/controls/TimeDisplay/TimeDisplay.test.tsx new file mode 100644 index 0000000..e9de09d --- /dev/null +++ b/src/components/controls/TimeDisplay/TimeDisplay.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TimeDisplay } from './TimeDisplay'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderTimeDisplay(props: Partial[0]> = {}) { + const defaults = { + currentTime: 0, + duration: 0, + }; + return render(, { wrapper: Wrapper }); +} + +describe('TimeDisplay', () => { + // ── Basic rendering ───────────────────────────────────────────────── + + it('renders current time and duration', () => { + renderTimeDisplay({ currentTime: 65, duration: 180 }); + expect(screen.getByText('1:05')).toBeInTheDocument(); + expect(screen.getByText('3:00')).toBeInTheDocument(); + }); + + it('renders the time separator', () => { + renderTimeDisplay({ currentTime: 10, duration: 60 }); + expect(screen.getByText('/')).toBeInTheDocument(); + }); + + it('displays formatted time in MM:SS format', () => { + renderTimeDisplay({ currentTime: 125, duration: 300 }); + expect(screen.getByText('2:05')).toBeInTheDocument(); + expect(screen.getByText('5:00')).toBeInTheDocument(); + }); + + it('displays HH:MM:SS format for long durations', () => { + renderTimeDisplay({ currentTime: 3661, duration: 7200 }); + expect(screen.getByText('1:01:01')).toBeInTheDocument(); + expect(screen.getByText('2:00:00')).toBeInTheDocument(); + }); + + // ── Remaining mode ────────────────────────────────────────────────── + + it('shows remaining time when showRemaining is true', () => { + renderTimeDisplay({ currentTime: 60, duration: 180, showRemaining: true }); + expect(screen.getByText('1:00')).toBeInTheDocument(); + // Remaining: 180 - 60 = 120 -> -2:00 + expect(screen.getByText('-2:00')).toBeInTheDocument(); + }); + + it('shows total duration when showRemaining is false', () => { + renderTimeDisplay({ currentTime: 60, duration: 180, showRemaining: false }); + expect(screen.getByText('3:00')).toBeInTheDocument(); + expect(screen.queryByText('-2:00')).not.toBeInTheDocument(); + }); + + it('shows zero remaining at the end of playback', () => { + renderTimeDisplay({ currentTime: 180, duration: 180, showRemaining: true }); + expect(screen.getByText('-0:00')).toBeInTheDocument(); + }); + + // ── Zero times ────────────────────────────────────────────────────── + + it('renders 0:00 for zero currentTime and duration', () => { + renderTimeDisplay({ currentTime: 0, duration: 0 }); + const zeros = screen.getAllByText('0:00'); + expect(zeros.length).toBe(2); + }); + + it('renders 0:00 for zero currentTime', () => { + renderTimeDisplay({ currentTime: 0, duration: 120 }); + expect(screen.getByText('0:00')).toBeInTheDocument(); + expect(screen.getByText('2:00')).toBeInTheDocument(); + }); + + // ── className ─────────────────────────────────────────────────────── + + it('applies custom className', () => { + const { container } = renderTimeDisplay({ className: 'my-time-class' }); + expect(container.firstElementChild?.className).toContain('my-time-class'); + }); + + // ── Accessibility ─────────────────────────────────────────────────── + + it('has an aria-label with time information', () => { + renderTimeDisplay({ currentTime: 65, duration: 180 }); + const el = screen.getByLabelText('1:05 of 3:00'); + expect(el).toBeInTheDocument(); + }); + + it('updates aria-label when time changes', () => { + const { rerender } = render( + , + { wrapper: Wrapper } + ); + expect(screen.getByLabelText('0:10 of 1:00')).toBeInTheDocument(); + + rerender(); + expect(screen.getByLabelText('0:30 of 1:00')).toBeInTheDocument(); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom separator label', () => { + renderTimeDisplay({ + currentTime: 10, + duration: 60, + labels: { timeSeparator: '|' }, + }); + expect(screen.getByText('|')).toBeInTheDocument(); + expect(screen.queryByText('/')).not.toBeInTheDocument(); + }); + + // ── Tabular nums ─────────────────────────────────────────────────── + + it('applies tabular-nums class for monospaced digits', () => { + const { container } = renderTimeDisplay({ currentTime: 10, duration: 60 }); + expect(container.firstElementChild?.className).toContain('tabular-nums'); + }); +}); diff --git a/src/components/controls/VolumeControl/VolumeControl.test.tsx b/src/components/controls/VolumeControl/VolumeControl.test.tsx new file mode 100644 index 0000000..9999313 --- /dev/null +++ b/src/components/controls/VolumeControl/VolumeControl.test.tsx @@ -0,0 +1,426 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { VolumeControl } from './VolumeControl'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderVolumeControl( + props: Partial[0]> = {} +) { + const defaults = { + volume: 0.75, + muted: false, + onVolumeChange: vi.fn(), + onMuteToggle: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +function mockSliderRect(el: Element) { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 96, + top: 0, + bottom: 24, + width: 96, + height: 24, + x: 0, + y: 0, + toJSON: () => {}, + }); +} + +function mockVerticalSliderRect(el: Element) { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 24, + top: 0, + bottom: 96, + width: 24, + height: 96, + x: 0, + y: 0, + toJSON: () => {}, + }); +} + +describe('VolumeControl', () => { + // ── Mute button ─────────────────────────────────────────────────── + + it('renders mute button with correct label when not muted', () => { + renderVolumeControl({ muted: false }); + expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument(); + }); + + it('renders unmute button with correct label when muted', () => { + renderVolumeControl({ muted: true }); + expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument(); + }); + + it('calls onMuteToggle when mute button is clicked', () => { + const onMuteToggle = vi.fn(); + renderVolumeControl({ onMuteToggle }); + fireEvent.click(screen.getByRole('button', { name: 'Mute' })); + expect(onMuteToggle).toHaveBeenCalledTimes(1); + }); + + it('disables mute button when disabled', () => { + renderVolumeControl({ disabled: true }); + expect(screen.getByRole('button', { name: 'Mute' })).toBeDisabled(); + }); + + it('applies disabled styling to mute button', () => { + renderVolumeControl({ disabled: true }); + expect(screen.getByRole('button', { name: 'Mute' }).className).toContain('opacity-50'); + }); + + // ── Volume icon states ──────────────────────────────────────────── + + it('shows muted icon (X lines) when volume is 0', () => { + renderVolumeControl({ volume: 0, muted: false }); + const btn = screen.getByRole('button', { name: 'Mute' }); + const svg = btn.querySelector('svg')!; + // Muted icon has line elements + const lines = svg.querySelectorAll('line'); + expect(lines.length).toBe(2); + }); + + it('shows muted icon when muted is true regardless of volume', () => { + renderVolumeControl({ volume: 0.8, muted: true }); + const btn = screen.getByRole('button', { name: 'Unmute' }); + const svg = btn.querySelector('svg')!; + // effectiveVolume = 0 when muted + const lines = svg.querySelectorAll('line'); + expect(lines.length).toBe(2); + }); + + it('shows low volume icon (one wave) for volume < 0.5', () => { + renderVolumeControl({ volume: 0.3, muted: false }); + const btn = screen.getByRole('button', { name: 'Mute' }); + const svg = btn.querySelector('svg')!; + const paths = svg.querySelectorAll('g[fill="currentColor"] > path'); + // First wave visible (opacity 1), second hidden (opacity 0) + expect(paths[0]?.getAttribute('style')).toContain('opacity: 1'); + expect(paths[1]?.getAttribute('style')).toContain('opacity: 0'); + }); + + it('shows full volume icon (two waves) for volume >= 0.5', () => { + renderVolumeControl({ volume: 0.75, muted: false }); + const btn = screen.getByRole('button', { name: 'Mute' }); + const svg = btn.querySelector('svg')!; + const paths = svg.querySelectorAll('g[fill="currentColor"] > path'); + // Both waves visible + expect(paths[0]?.getAttribute('style')).toContain('opacity: 1'); + expect(paths[1]?.getAttribute('style')).toContain('opacity: 1'); + }); + + // ── Horizontal layout ───────────────────────────────────────────── + + it('renders inline slider in horizontal mode', () => { + renderVolumeControl({ orientation: 'horizontal' }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-orientation')).toBe('horizontal'); + }); + + it('shows correct volume percentage as aria value (horizontal)', () => { + renderVolumeControl({ orientation: 'horizontal', volume: 0.75 }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-valuenow')).toBe('75'); + expect(slider.getAttribute('aria-valuetext')).toBe('75%'); + }); + + it('shows 0% when muted (horizontal)', () => { + renderVolumeControl({ orientation: 'horizontal', volume: 0.75, muted: true }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('has correct aria-valuemin and aria-valuemax (horizontal)', () => { + renderVolumeControl({ orientation: 'horizontal' }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-valuemin')).toBe('0'); + expect(slider.getAttribute('aria-valuemax')).toBe('100'); + }); + + it('has correct aria-label (horizontal)', () => { + renderVolumeControl({ orientation: 'horizontal' }); + expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument(); + }); + + it('calls onVolumeChange on slider click (horizontal)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', onVolumeChange }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 }); + // 48/96 = 0.5 + expect(onVolumeChange).toHaveBeenCalledWith(0.5); + }); + + it('does not change volume on slider click when disabled (horizontal)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', onVolumeChange, disabled: true }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 }); + expect(onVolumeChange).not.toHaveBeenCalled(); + }); + + it('calls onVolumeChange on drag (horizontal)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', onVolumeChange }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + + fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 }); + onVolumeChange.mockClear(); + + // Global mousemove + fireEvent.mouseMove(document, { clientX: 72, clientY: 12 }); + expect(onVolumeChange).toHaveBeenCalled(); + }); + + it('stops dragging on global mouseup (horizontal)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', onVolumeChange }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + + fireEvent.mouseDown(slider, { clientX: 48, clientY: 12 }); + fireEvent.mouseUp(document); + onVolumeChange.mockClear(); + + // After mouseup, mousemove should not call onVolumeChange + fireEvent.mouseMove(document, { clientX: 72, clientY: 12 }); + expect(onVolumeChange).not.toHaveBeenCalled(); + }); + + it('clamps volume to 0-1 range on slider click (horizontal)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', onVolumeChange }); + const slider = screen.getByRole('slider'); + mockSliderRect(slider); + + // Click beyond right edge + fireEvent.mouseDown(slider, { clientX: 200, clientY: 12 }); + expect(onVolumeChange).toHaveBeenCalledWith(1); + + onVolumeChange.mockClear(); + // Click before left edge + fireEvent.mouseDown(slider, { clientX: -50, clientY: 12 }); + expect(onVolumeChange).toHaveBeenCalledWith(0); + }); + + // ── Vertical layout ─────────────────────────────────────────────── + + it('renders vertical slider overlay on hover (vertical)', () => { + renderVolumeControl({ orientation: 'vertical' }); + const container = screen.getByRole('button', { name: 'Mute' }).parentElement!; + fireEvent.mouseEnter(container); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('shows volume percentage text in vertical overlay', () => { + renderVolumeControl({ orientation: 'vertical', volume: 0.75 }); + const container = screen.getByRole('button', { name: 'Mute' }).parentElement!; + fireEvent.mouseEnter(container); + expect(screen.getByText('75%')).toBeInTheDocument(); + }); + + it('shows 0% when muted in vertical overlay', () => { + renderVolumeControl({ orientation: 'vertical', volume: 0.75, muted: true }); + const container = screen.getByRole('button', { name: 'Unmute' }).parentElement!; + fireEvent.mouseEnter(container); + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + + it('calls onVolumeChange on slider click (vertical)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'vertical', onVolumeChange }); + const container = screen.getByRole('button', { name: 'Mute' }).parentElement!; + fireEvent.mouseEnter(container); + + const slider = screen.getByRole('slider'); + mockVerticalSliderRect(slider); + // Click at vertical position: 1 - (48/96) = 0.5 + fireEvent.mouseDown(slider, { clientX: 12, clientY: 48 }); + expect(onVolumeChange).toHaveBeenCalledWith(0.5); + }); + + it('hides vertical slider on mouse leave (when not dragging)', () => { + renderVolumeControl({ orientation: 'vertical' }); + const container = screen.getByRole('button', { name: 'Mute' }).parentElement!; + fireEvent.mouseEnter(container); + // Slider should be visible (opacity-100) + const overlay = container.querySelector('.absolute.bottom-full'); + expect(overlay?.className).toContain('opacity-100'); + + fireEvent.mouseLeave(container); + expect(overlay?.className).toContain('opacity-0'); + }); + + // ── Keyboard interaction ────────────────────────────────────────── + + it('increases volume on ArrowUp key (horizontal)', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowUp' }); + // step = 0.05, so 0.5 + 0.05 = 0.55 + expect(onVolumeChange).toHaveBeenCalledWith(0.55); + }); + + it('increases volume on ArrowRight key', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + expect(onVolumeChange).toHaveBeenCalledWith(0.55); + }); + + it('decreases volume on ArrowDown key', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowDown' }); + expect(onVolumeChange).toHaveBeenCalledWith(0.45); + }); + + it('decreases volume on ArrowLeft key', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowLeft' }); + expect(onVolumeChange).toHaveBeenCalledWith(0.45); + }); + + it('uses large step (0.1) with Shift key', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowUp', shiftKey: true }); + expect(onVolumeChange).toHaveBeenCalledWith(0.6); + }); + + it('clamps volume to max 1 on keyboard', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.98, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowUp' }); + expect(onVolumeChange).toHaveBeenCalledWith(1); + }); + + it('clamps volume to min 0 on keyboard', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.02, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowDown' }); + expect(onVolumeChange).toHaveBeenCalledWith(0); + }); + + it('ignores keyboard when disabled', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange, disabled: true }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowUp' }); + expect(onVolumeChange).not.toHaveBeenCalled(); + }); + + it('ignores unrelated keys', () => { + const onVolumeChange = vi.fn(); + renderVolumeControl({ orientation: 'horizontal', volume: 0.5, onVolumeChange }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'a' }); + expect(onVolumeChange).not.toHaveBeenCalled(); + }); + + // ── className passthrough ───────────────────────────────────────── + + it('applies custom className (horizontal)', () => { + const { container } = renderVolumeControl({ orientation: 'horizontal', className: 'my-volume' }); + expect(container.firstChild).toHaveClass('my-volume'); + }); + + it('applies custom className (vertical)', () => { + const { container } = renderVolumeControl({ orientation: 'vertical', className: 'my-volume' }); + expect(container.firstChild).toHaveClass('my-volume'); + }); + + // ── Custom labels ───────────────────────────────────────────────── + + it('uses custom mute label', () => { + renderVolumeControl({ + muted: false, + labels: { mute: 'Stummschalten', unmute: 'Laut', volume: 'Lautst\u00e4rke' }, + }); + expect(screen.getByRole('button', { name: 'Stummschalten' })).toBeInTheDocument(); + }); + + it('uses custom unmute label', () => { + renderVolumeControl({ + muted: true, + labels: { mute: 'Stummschalten', unmute: 'Laut', volume: 'Lautst\u00e4rke' }, + }); + expect(screen.getByRole('button', { name: 'Laut' })).toBeInTheDocument(); + }); + + it('uses custom volume aria-label on slider', () => { + renderVolumeControl({ + orientation: 'horizontal', + labels: { mute: 'Stummschalten', unmute: 'Laut', volume: 'Lautst\u00e4rke' }, + }); + expect(screen.getByRole('slider', { name: 'Lautst\u00e4rke' })).toBeInTheDocument(); + }); + + // ── Close on outside click ──────────────────────────────────────── + + it('closes vertical slider on outside click', () => { + renderVolumeControl({ orientation: 'vertical' }); + const container = screen.getByRole('button', { name: 'Mute' }).parentElement!; + fireEvent.mouseEnter(container); + const overlay = container.querySelector('.absolute.bottom-full'); + expect(overlay?.className).toContain('opacity-100'); + + // Click outside + fireEvent.mouseDown(document.body); + expect(overlay?.className).toContain('opacity-0'); + }); + + // ── Slider not focusable when disabled ──────────────────────────── + + it('slider has tabIndex -1 when disabled (horizontal)', () => { + renderVolumeControl({ orientation: 'horizontal', disabled: true }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('tabindex')).toBe('-1'); + }); + + it('slider has tabIndex 0 when enabled (horizontal)', () => { + renderVolumeControl({ orientation: 'horizontal', disabled: false }); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('tabindex')).toBe('0'); + }); + + // ── Volume fill width ───────────────────────────────────────────── + + it('shows correct volume fill width (horizontal)', () => { + const { container } = renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: false }); + const slider = screen.getByRole('slider'); + // Volume fill is inside the slider + const fill = slider.querySelector('.absolute.left-0.top-0.bottom-0'); + expect((fill as HTMLElement)?.style.width).toBe('60%'); + }); + + it('shows 0% fill when muted (horizontal)', () => { + const { container } = renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: true }); + const slider = screen.getByRole('slider'); + const fill = slider.querySelector('.absolute.left-0.top-0.bottom-0'); + expect((fill as HTMLElement)?.style.width).toBe('0%'); + }); +}); diff --git a/src/components/markers/MarkerList/MarkerList.test.tsx b/src/components/markers/MarkerList/MarkerList.test.tsx new file mode 100644 index 0000000..e38ac24 --- /dev/null +++ b/src/components/markers/MarkerList/MarkerList.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MarkerList } from './MarkerList'; +import type { TimelineMarker } from '@/types/markers'; + +const markers: TimelineMarker[] = [ + { id: 'm-1', time: 15, title: 'Highlight 1' }, + { id: 'm-2', time: 60, title: 'Highlight 2' }, + { id: 'm-3', time: 120, title: 'Highlight 3' }, +]; + +function renderMarkerList(props: Partial[0]> = {}) { + const defaults = { + markers, + currentTime: 0, + duration: 180, + onMarkerClick: vi.fn(), + }; + return render(); +} + +describe('MarkerList', () => { + // ── Rendering ────────────────────────────────────────────────────── + + it('renders the "Markers" heading', () => { + renderMarkerList(); + expect(screen.getByText('Markers')).toBeInTheDocument(); + }); + + it('renders all marker titles', () => { + renderMarkerList(); + expect(screen.getByText('Highlight 1')).toBeInTheDocument(); + expect(screen.getByText('Highlight 2')).toBeInTheDocument(); + expect(screen.getByText('Highlight 3')).toBeInTheDocument(); + }); + + it('renders a list with marker items', () => { + renderMarkerList(); + expect(screen.getByRole('list', { name: 'Marker list' })).toBeInTheDocument(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + }); + + it('does not render when markers array is empty', () => { + const { container } = renderMarkerList({ markers: [] }); + expect(container.innerHTML).toBe(''); + }); + + // ── Marker times ─────────────────────────────────────────────────── + + it('shows formatted time for each marker', () => { + renderMarkerList(); + expect(screen.getByText('0:15')).toBeInTheDocument(); + expect(screen.getByText('1:00')).toBeInTheDocument(); + expect(screen.getByText('2:00')).toBeInTheDocument(); + }); + + // ── Active marker ────────────────────────────────────────────────── + + it('marks the active marker with aria-current', () => { + renderMarkerList({ activeMarkerIndex: 1 }); + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).not.toHaveAttribute('aria-current'); + expect(buttons[1]).toHaveAttribute('aria-current', 'true'); + expect(buttons[2]).not.toHaveAttribute('aria-current'); + }); + + it('applies active background to current marker', () => { + renderMarkerList({ activeMarkerIndex: 0 }); + const buttons = screen.getAllByRole('button'); + expect(buttons[0].className).toContain('bg-[var(--fp-color-surface)]'); + }); + + it('shows active indicator dot for active marker', () => { + renderMarkerList({ activeMarkerIndex: 1 }); + const activeButton = screen.getAllByRole('button')[1]; + const dot = activeButton.querySelector('.rounded-full'); + expect(dot).toBeTruthy(); + }); + + it('applies accent text color to active marker title', () => { + renderMarkerList({ activeMarkerIndex: 0 }); + const title = screen.getByText('Highlight 1'); + expect(title.className).toContain('text-[var(--fp-color-accent)]'); + expect(title.className).toContain('font-medium'); + }); + + it('does not highlight any marker when activeMarkerIndex is -1', () => { + renderMarkerList({ activeMarkerIndex: -1 }); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).not.toHaveAttribute('aria-current'); + }); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onMarkerClick with marker and index when clicked', () => { + const onMarkerClick = vi.fn(); + renderMarkerList({ onMarkerClick }); + fireEvent.click(screen.getByText('Highlight 2')); + expect(onMarkerClick).toHaveBeenCalledWith(markers[1], 1); + }); + + it('calls onMarkerClick for first marker', () => { + const onMarkerClick = vi.fn(); + renderMarkerList({ onMarkerClick }); + fireEvent.click(screen.getByText('Highlight 1')); + expect(onMarkerClick).toHaveBeenCalledWith(markers[0], 0); + }); + + it('calls onMarkerClick for last marker', () => { + const onMarkerClick = vi.fn(); + renderMarkerList({ onMarkerClick }); + fireEvent.click(screen.getByText('Highlight 3')); + expect(onMarkerClick).toHaveBeenCalledWith(markers[2], 2); + }); + + // ── Preview images ───────────────────────────────────────────────── + + it('shows preview images when showPreviewImage is true and images exist', () => { + const markersWithImages: TimelineMarker[] = markers.map((m) => ({ + ...m, + previewImage: `https://example.com/${m.id}.jpg`, + })); + const { container } = renderMarkerList({ markers: markersWithImages, showPreviewImage: true }); + const images = container.querySelectorAll('img'); + expect(images.length).toBe(3); + }); + + it('hides preview images when showPreviewImage is false', () => { + const markersWithImages: TimelineMarker[] = markers.map((m) => ({ + ...m, + previewImage: `https://example.com/${m.id}.jpg`, + })); + const { container } = renderMarkerList({ markers: markersWithImages, showPreviewImage: false }); + expect(container.querySelectorAll('img')).toHaveLength(0); + }); + + it('does not show images when markers have no previewImage', () => { + const { container } = renderMarkerList({ showPreviewImage: true }); + expect(container.querySelectorAll('img')).toHaveLength(0); + }); + + // ── Markers without titles ───────────────────────────────────────── + + it('handles markers without title', () => { + const noTitleMarkers: TimelineMarker[] = [ + { id: 'm-1', time: 10 }, + { id: 'm-2', time: 50, title: 'Has Title' }, + ]; + renderMarkerList({ markers: noTitleMarkers }); + expect(screen.getByText('Has Title')).toBeInTheDocument(); + expect(screen.getByText('0:10')).toBeInTheDocument(); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the container', () => { + const { container } = renderMarkerList({ className: 'my-markers' }); + expect(container.firstElementChild?.className).toContain('my-markers'); + }); + + // ── Marker with custom color ─────────────────────────────────────── + + it('applies custom color to active marker dot', () => { + const colorMarkers: TimelineMarker[] = [ + { id: 'm-1', time: 10, title: 'Red', color: '#ff0000' }, + ]; + renderMarkerList({ markers: colorMarkers, activeMarkerIndex: 0 }); + const dot = screen.getByRole('button').querySelector('.rounded-full'); + expect(dot).toBeTruthy(); + expect((dot as HTMLElement).style.backgroundColor).toBe('rgb(255, 0, 0)'); + }); +}); diff --git a/src/components/playlist/PlaylistView/PlaylistView.test.tsx b/src/components/playlist/PlaylistView/PlaylistView.test.tsx new file mode 100644 index 0000000..ec5719b --- /dev/null +++ b/src/components/playlist/PlaylistView/PlaylistView.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PlaylistView } from './PlaylistView'; +import { createMockPlaylist } from '@/test/helpers'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; +import type { Track } from '@/types/player'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +const tracks = createMockPlaylist(3); + +function renderPlaylistView(props: Partial[0]> = {}) { + const defaults = { + tracks, + currentIndex: 0, + onTrackClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('PlaylistView', () => { + // ── Rendering ────────────────────────────────────────────────────── + + it('renders the "Playlist" heading', () => { + renderPlaylistView(); + expect(screen.getByText('Playlist')).toBeInTheDocument(); + }); + + it('renders the track count', () => { + renderPlaylistView(); + expect(screen.getByText('3 tracks')).toBeInTheDocument(); + }); + + it('renders singular "track" for single track', () => { + renderPlaylistView({ tracks: [tracks[0]] }); + expect(screen.getByText('1 track')).toBeInTheDocument(); + }); + + it('renders all track titles', () => { + renderPlaylistView(); + expect(screen.getByText('Track 1')).toBeInTheDocument(); + expect(screen.getByText('Track 2')).toBeInTheDocument(); + expect(screen.getByText('Track 3')).toBeInTheDocument(); + }); + + it('renders a list with role and label', () => { + renderPlaylistView(); + expect(screen.getByRole('list', { name: 'Playlist' })).toBeInTheDocument(); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + }); + + it('does not render when tracks array is empty', () => { + const { container } = renderPlaylistView({ tracks: [] }); + expect(container.innerHTML).toBe(''); + }); + + // ── Current track highlighting ───────────────────────────────────── + + it('marks the current track as active', () => { + renderPlaylistView({ currentIndex: 1 }); + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).not.toHaveAttribute('aria-current'); + expect(buttons[1]).toHaveAttribute('aria-current', 'true'); + expect(buttons[2]).not.toHaveAttribute('aria-current'); + }); + + it('highlights the first track by default', () => { + renderPlaylistView({ currentIndex: 0 }); + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('aria-current', 'true'); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onTrackClick with track and index when clicked', () => { + const onTrackClick = vi.fn(); + renderPlaylistView({ onTrackClick }); + fireEvent.click(screen.getByText('Track 2')); + expect(onTrackClick).toHaveBeenCalledWith(tracks[1], 1); + }); + + it('calls onTrackClick for the first track', () => { + const onTrackClick = vi.fn(); + renderPlaylistView({ onTrackClick }); + fireEvent.click(screen.getByText('Track 1')); + expect(onTrackClick).toHaveBeenCalledWith(tracks[0], 0); + }); + + it('calls onTrackClick for the last track', () => { + const onTrackClick = vi.fn(); + renderPlaylistView({ onTrackClick }); + fireEvent.click(screen.getByText('Track 3')); + expect(onTrackClick).toHaveBeenCalledWith(tracks[2], 2); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the container', () => { + const { container } = renderPlaylistView({ className: 'my-playlist' }); + expect(container.firstElementChild?.className).toContain('my-playlist'); + }); + + // ── maxHeight ────────────────────────────────────────────────────── + + it('applies default maxHeight of 300px to the list', () => { + renderPlaylistView(); + const list = screen.getByRole('list'); + expect(list.style.maxHeight).toBe('300px'); + }); + + it('applies custom maxHeight', () => { + renderPlaylistView({ maxHeight: '500px' }); + const list = screen.getByRole('list'); + expect(list.style.maxHeight).toBe('500px'); + }); + + // ── Track info ───────────────────────────────────────────────────── + + it('renders track artist names', () => { + renderPlaylistView(); + expect(screen.getByText('Artist 1')).toBeInTheDocument(); + expect(screen.getByText('Artist 2')).toBeInTheDocument(); + expect(screen.getByText('Artist 3')).toBeInTheDocument(); + }); + + // ── isPlaying ────────────────────────────────────────────────────── + + it('renders with isPlaying false by default', () => { + renderPlaylistView({ currentIndex: 0 }); + // When not playing, should show track number instead of NowPlayingIndicator + expect(screen.getByText('1')).toBeInTheDocument(); + }); +}); diff --git a/src/components/playlist/TrackItem/TrackItem.test.tsx b/src/components/playlist/TrackItem/TrackItem.test.tsx new file mode 100644 index 0000000..2e5a087 --- /dev/null +++ b/src/components/playlist/TrackItem/TrackItem.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TrackItem } from './TrackItem'; +import { createMockTrack } from '@/test/helpers'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; +import type { Track } from '@/types/player'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +const track = createMockTrack(); + +function renderTrackItem(props: Partial[0]> = {}) { + const defaults = { + track, + index: 0, + onClick: vi.fn(), + }; + return render(, { wrapper: Wrapper }); +} + +describe('TrackItem', () => { + // ── Rendering track info ─────────────────────────────────────────── + + it('renders the track title', () => { + renderTrackItem(); + expect(screen.getByText('Test Track')).toBeInTheDocument(); + }); + + it('renders the track artist', () => { + renderTrackItem(); + expect(screen.getByText('Test Artist')).toBeInTheDocument(); + }); + + it('renders "Untitled" when track has no title', () => { + renderTrackItem({ track: createMockTrack({ title: undefined }) }); + expect(screen.getByText('Untitled')).toBeInTheDocument(); + }); + + it('does not render artist when not provided', () => { + renderTrackItem({ track: createMockTrack({ artist: undefined }) }); + expect(screen.queryByText('Test Artist')).not.toBeInTheDocument(); + }); + + // ── Track number ─────────────────────────────────────────────────── + + it('shows 1-based track number when not active', () => { + renderTrackItem({ index: 0 }); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('shows correct track number for different indices', () => { + renderTrackItem({ index: 4 }); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('shows track number when active but not playing', () => { + renderTrackItem({ isActive: true, isPlaying: false, index: 2 }); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + // ── Now playing indicator ────────────────────────────────────────── + + it('shows NowPlayingIndicator when active and playing', () => { + renderTrackItem({ isActive: true, isPlaying: true }); + // The NowPlayingIndicator renders with role="img" + expect(screen.getByRole('img')).toBeInTheDocument(); + }); + + it('does not show NowPlayingIndicator when not active', () => { + renderTrackItem({ isActive: false, isPlaying: false }); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + // ── Artwork ──────────────────────────────────────────────────────── + + it('renders artwork image when provided', () => { + const { container } = renderTrackItem(); + const img = container.querySelector('img') as HTMLImageElement | null; + expect(img).toBeTruthy(); + expect(img?.src).toBe('https://example.com/cover.jpg'); + }); + + it('does not render artwork when not provided', () => { + const { container } = renderTrackItem({ track: createMockTrack({ artwork: undefined }) }); + expect(container.querySelector('img')).toBeNull(); + }); + + // ── Duration ─────────────────────────────────────────────────────── + + it('renders formatted duration', () => { + renderTrackItem({ track: createMockTrack({ duration: 180 }) }); + expect(screen.getByText('3:00')).toBeInTheDocument(); + }); + + it('does not render duration when not provided', () => { + renderTrackItem({ track: createMockTrack({ duration: undefined }) }); + expect(screen.queryByText('3:00')).not.toBeInTheDocument(); + }); + + // ── Active state ─────────────────────────────────────────────────── + + it('marks active track with aria-current', () => { + renderTrackItem({ isActive: true }); + expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'true'); + }); + + it('does not mark inactive track with aria-current', () => { + renderTrackItem({ isActive: false }); + expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + }); + + it('applies active background styling', () => { + renderTrackItem({ isActive: true }); + expect(screen.getByRole('button').className).toContain('bg-[var(--fp-color-surface)]'); + }); + + it('applies active text color to title', () => { + renderTrackItem({ isActive: true }); + const title = screen.getByText('Test Track'); + expect(title.className).toContain('text-[var(--fp-color-primary)]'); + expect(title.className).toContain('font-medium'); + }); + + // ── Click handler ────────────────────────────────────────────────── + + it('calls onClick with track and index when clicked', () => { + const onClick = vi.fn(); + renderTrackItem({ onClick, index: 2 }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledWith(track, 2); + }); + + it('calls onClick with a different track', () => { + const onClick = vi.fn(); + const otherTrack = createMockTrack({ id: 'other', title: 'Other Track' }); + renderTrackItem({ track: otherTrack, onClick, index: 0 }); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledWith(otherTrack, 0); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the button', () => { + renderTrackItem({ className: 'my-track-item' }); + expect(screen.getByRole('button').className).toContain('my-track-item'); + }); + + // ── Button type ──────────────────────────────────────────────────── + + it('renders as a button with type="button"', () => { + renderTrackItem(); + const btn = screen.getByRole('button'); + expect(btn.tagName).toBe('BUTTON'); + expect(btn.getAttribute('type')).toBe('button'); + }); +}); diff --git a/src/components/stats/Rating.test.tsx b/src/components/stats/Rating.test.tsx new file mode 100644 index 0000000..7a56cb0 --- /dev/null +++ b/src/components/stats/Rating.test.tsx @@ -0,0 +1,278 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Rating } from './Rating'; +import { LabelsProvider } from '@/context/LabelsContext'; +import type { ReactNode } from 'react'; + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function renderRating(props: Partial[0]> = {}) { + return render(, { wrapper: Wrapper }); +} + +describe('Rating', () => { + // ── Default rendering ────────────────────────────────────────────── + + it('renders thumbs up and thumbs down buttons', () => { + renderRating(); + expect(screen.getByRole('button', { name: 'Like' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dislike' })).toBeInTheDocument(); + }); + + it('renders with no user rating by default', () => { + renderRating(); + // Both buttons should show unfilled state (stroke only) + const upBtn = screen.getByRole('button', { name: 'Like' }); + const downBtn = screen.getByRole('button', { name: 'Dislike' }); + expect(upBtn.className).toContain('text-gray-400'); + expect(downBtn.className).toContain('text-gray-400'); + }); + + it('shows count of 0 by default', () => { + renderRating(); + const zeros = screen.getAllByText('0'); + expect(zeros.length).toBe(2); // up count and down count + }); + + // ── Click up ─────────────────────────────────────────────────────── + + it('calls onRateUp when thumbs up is clicked', () => { + const onRateUp = vi.fn(); + renderRating({ onRateUp }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRateUp).toHaveBeenCalledTimes(1); + }); + + it('calls onRatingChange with "up" when thumbs up is clicked', () => { + const onRatingChange = vi.fn(); + renderRating({ onRatingChange }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRatingChange).toHaveBeenCalledWith('up'); + }); + + it('increments up count when thumbs up is clicked', () => { + renderRating({ initialState: { upCount: 10, downCount: 5 } }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(screen.getByText('11')).toBeInTheDocument(); + }); + + // ── Click down ───────────────────────────────────────────────────── + + it('calls onRateDown when thumbs down is clicked', () => { + const onRateDown = vi.fn(); + renderRating({ onRateDown }); + fireEvent.click(screen.getByRole('button', { name: 'Dislike' })); + expect(onRateDown).toHaveBeenCalledTimes(1); + }); + + it('calls onRatingChange with "down" when thumbs down is clicked', () => { + const onRatingChange = vi.fn(); + renderRating({ onRatingChange }); + fireEvent.click(screen.getByRole('button', { name: 'Dislike' })); + expect(onRatingChange).toHaveBeenCalledWith('down'); + }); + + it('increments down count when thumbs down is clicked', () => { + renderRating({ initialState: { upCount: 10, downCount: 5 } }); + fireEvent.click(screen.getByRole('button', { name: 'Dislike' })); + expect(screen.getByText('6')).toBeInTheDocument(); + }); + + // ── Toggle rating ────────────────────────────────────────────────── + + it('removes rating when same button is clicked twice', () => { + const onRatingChange = vi.fn(); + renderRating({ onRatingChange }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRatingChange).toHaveBeenCalledWith('up'); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRatingChange).toHaveBeenCalledWith(null); + }); + + it('calls onRateRemove when removing a rating', () => { + const onRateRemove = vi.fn(); + renderRating({ onRateRemove, initialState: { userRating: 'up', upCount: 1 } }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRateRemove).toHaveBeenCalledTimes(1); + }); + + it('switches from up to down correctly', () => { + const onRatingChange = vi.fn(); + renderRating({ onRatingChange, initialState: { upCount: 5, downCount: 3 } }); + // Click up + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRatingChange).toHaveBeenLastCalledWith('up'); + // Click down (switches from up to down) + fireEvent.click(screen.getByRole('button', { name: 'Dislike' })); + expect(onRatingChange).toHaveBeenLastCalledWith('down'); + }); + + // ── Initial state ────────────────────────────────────────────────── + + it('renders with initial upCount and downCount', () => { + renderRating({ initialState: { upCount: 42, downCount: 8 } }); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + }); + + it('renders with initial userRating of up', () => { + renderRating({ initialState: { userRating: 'up', upCount: 10 } }); + const upBtn = screen.getByRole('button', { name: 'Like' }); + expect(upBtn.className).toContain('text-green-500'); + }); + + it('renders with initial userRating of down', () => { + renderRating({ initialState: { userRating: 'down', downCount: 5 } }); + const downBtn = screen.getByRole('button', { name: 'Dislike' }); + expect(downBtn.className).toContain('text-red-500'); + }); + + // ── Disabled state ───────────────────────────────────────────────── + + it('disables both buttons when disabled', () => { + renderRating({ disabled: true }); + expect(screen.getByRole('button', { name: 'Like' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Dislike' })).toBeDisabled(); + }); + + it('applies disabled styling', () => { + renderRating({ disabled: true }); + const upBtn = screen.getByRole('button', { name: 'Like' }); + expect(upBtn.className).toContain('opacity-50'); + }); + + it('does not call callbacks when disabled', () => { + const onRateUp = vi.fn(); + const onRateDown = vi.fn(); + renderRating({ disabled: true, onRateUp, onRateDown }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + fireEvent.click(screen.getByRole('button', { name: 'Dislike' })); + expect(onRateUp).not.toHaveBeenCalled(); + expect(onRateDown).not.toHaveBeenCalled(); + }); + + // ── showCounts ───────────────────────────────────────────────────── + + it('hides counts when showCounts is false', () => { + renderRating({ + showCounts: false, + initialState: { upCount: 42, downCount: 8 }, + }); + expect(screen.queryByText('42')).not.toBeInTheDocument(); + expect(screen.queryByText('8')).not.toBeInTheDocument(); + }); + + it('shows counts when showCounts is true (default)', () => { + renderRating({ initialState: { upCount: 42, downCount: 8 } }); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + }); + + // ── showPercentage ───────────────────────────────────────────────── + + it('shows percentage instead of count when showPercentage is true', () => { + renderRating({ + showPercentage: true, + initialState: { upCount: 75, downCount: 25 }, + }); + expect(screen.getByText('75%')).toBeInTheDocument(); + }); + + it('shows raw count when total is 0 and showPercentage is true (no percentage when no votes)', () => { + renderRating({ + showPercentage: true, + initialState: { upCount: 0, downCount: 0 }, + }); + // When total is 0, falls back to showing raw upCount + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('does not show down count when showPercentage is true', () => { + renderRating({ + showPercentage: true, + initialState: { upCount: 75, downCount: 25 }, + }); + // Down count should not display as a number + expect(screen.queryByText('25')).not.toBeInTheDocument(); + }); + + // ── Size variants ────────────────────────────────────────────────── + + it('applies small size class', () => { + const { container } = renderRating({ size: 'sm' }); + expect(container.firstElementChild?.className).toContain('text-xs'); + }); + + it('applies medium size class by default', () => { + const { container } = renderRating(); + expect(container.firstElementChild?.className).toContain('text-sm'); + }); + + it('applies large size class', () => { + const { container } = renderRating({ size: 'lg' }); + expect(container.firstElementChild?.className).toContain('text-base'); + }); + + it('applies correct icon size for small', () => { + renderRating({ size: 'sm' }); + const svgs = screen.getByRole('button', { name: 'Like' }).querySelectorAll('svg'); + expect(svgs[0].getAttribute('class')).toContain('w-4'); + }); + + it('applies correct icon size for large', () => { + renderRating({ size: 'lg' }); + const svgs = screen.getByRole('button', { name: 'Like' }).querySelectorAll('svg'); + expect(svgs[0].getAttribute('class')).toContain('w-6'); + }); + + // ── Custom labels ────────────────────────────────────────────────── + + it('uses custom rateUp label', () => { + renderRating({ labels: { rateUp: 'Daumen hoch' } }); + expect(screen.getByRole('button', { name: 'Daumen hoch' })).toBeInTheDocument(); + }); + + it('uses custom rateDown label', () => { + renderRating({ labels: { rateDown: 'Daumen runter' } }); + expect(screen.getByRole('button', { name: 'Daumen runter' })).toBeInTheDocument(); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className', () => { + const { container } = renderRating({ className: 'my-rating' }); + expect(container.firstElementChild?.className).toContain('my-rating'); + }); + + // ── Controlled state ─────────────────────────────────────────────── + + it('uses controlled state when provided', () => { + renderRating({ + state: { userRating: 'up', upCount: 100, downCount: 50 }, + }); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('50')).toBeInTheDocument(); + const upBtn = screen.getByRole('button', { name: 'Like' }); + expect(upBtn.className).toContain('text-green-500'); + }); + + // ── canRate ──────────────────────────────────────────────────────── + + it('does not allow rating when canRate is false', () => { + const onRateUp = vi.fn(); + renderRating({ initialState: { canRate: false }, onRateUp }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRateUp).not.toHaveBeenCalled(); + }); + + // ── enabled ──────────────────────────────────────────────────────── + + it('does not allow rating when enabled is false', () => { + const onRateUp = vi.fn(); + renderRating({ initialState: { enabled: false }, onRateUp }); + fireEvent.click(screen.getByRole('button', { name: 'Like' })); + expect(onRateUp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/stats/Stats.test.tsx b/src/components/stats/Stats.test.tsx new file mode 100644 index 0000000..8e9be7d --- /dev/null +++ b/src/components/stats/Stats.test.tsx @@ -0,0 +1,241 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Stats, StatIcons } from './Stats'; +import type { StatItem } from '@/types/stats'; + +const items: StatItem[] = [ + { id: 'plays', label: 'Plays', value: 1234 }, + { id: 'likes', label: 'Likes', value: 567 }, + { id: 'comments', label: 'Comments', value: 89 }, +]; + +function renderStats(props: Partial[0]> = {}) { + const defaults = { + items, + }; + return render(); +} + +describe('Stats', () => { + // ── Rendering items ──────────────────────────────────────────────── + + it('renders all stat labels', () => { + renderStats(); + expect(screen.getByText('Plays:')).toBeInTheDocument(); + expect(screen.getByText('Likes:')).toBeInTheDocument(); + expect(screen.getByText('Comments:')).toBeInTheDocument(); + }); + + it('renders all stat values', () => { + renderStats(); + expect(screen.getByText('1234')).toBeInTheDocument(); + expect(screen.getByText('567')).toBeInTheDocument(); + expect(screen.getByText('89')).toBeInTheDocument(); + }); + + it('renders string values', () => { + const stringItems: StatItem[] = [ + { id: 'date', label: 'Published', value: 'Jan 1, 2024' }, + ]; + renderStats({ items: stringItems }); + expect(screen.getByText('Jan 1, 2024')).toBeInTheDocument(); + }); + + it('does not render when all items are empty', () => { + const { container } = renderStats({ items: [] }); + expect(container.innerHTML).toBe(''); + }); + + // ── Hidden items ─────────────────────────────────────────────────── + + it('does not render hidden items', () => { + const itemsWithHidden: StatItem[] = [ + { id: 'visible', label: 'Visible', value: 100 }, + { id: 'hidden', label: 'Hidden', value: 200, hidden: true }, + ]; + renderStats({ items: itemsWithHidden }); + expect(screen.getByText('Visible:')).toBeInTheDocument(); + expect(screen.queryByText('Hidden:')).not.toBeInTheDocument(); + }); + + it('renders nothing when all items are hidden', () => { + const allHidden: StatItem[] = [ + { id: 'a', label: 'A', value: 1, hidden: true }, + { id: 'b', label: 'B', value: 2, hidden: true }, + ]; + const { container } = renderStats({ items: allHidden }); + expect(container.innerHTML).toBe(''); + }); + + // ── Ordering ─────────────────────────────────────────────────────── + + it('sorts items by order property', () => { + const ordered: StatItem[] = [ + { id: 'c', label: 'Third', value: 3, order: 3 }, + { id: 'a', label: 'First', value: 1, order: 1 }, + { id: 'b', label: 'Second', value: 2, order: 2 }, + ]; + const { container } = renderStats({ items: ordered }); + const texts = container.querySelectorAll('.text-gray-400'); + expect(texts[0]).toHaveTextContent('First:'); + expect(texts[1]).toHaveTextContent('Second:'); + expect(texts[2]).toHaveTextContent('Third:'); + }); + + // ── Layout options ───────────────────────────────────────────────── + + it('applies horizontal layout by default', () => { + const { container } = renderStats(); + expect(container.firstElementChild?.className).toContain('flex'); + expect(container.firstElementChild?.className).toContain('flex-wrap'); + }); + + it('applies vertical layout', () => { + const { container } = renderStats({ layout: 'vertical' }); + expect(container.firstElementChild?.className).toContain('flex-col'); + }); + + it('applies grid layout', () => { + const { container } = renderStats({ layout: 'grid' }); + expect(container.firstElementChild?.className).toContain('grid'); + }); + + it('applies grid template columns based on columns prop', () => { + const { container } = renderStats({ layout: 'grid', columns: 3 }); + expect(container.firstElementChild?.getAttribute('style')).toContain('repeat(3'); + }); + + // ── Dividers ─────────────────────────────────────────────────────── + + it('shows dividers when showDividers is true and layout is horizontal', () => { + const { container } = renderStats({ showDividers: true, layout: 'horizontal' }); + const dividerElements = container.querySelectorAll('.border-l'); + // First item has no divider, subsequent items do + expect(dividerElements.length).toBe(2); + }); + + it('does not show dividers when showDividers is false', () => { + const { container } = renderStats({ showDividers: false }); + const dividerElements = container.querySelectorAll('.border-l'); + expect(dividerElements.length).toBe(0); + }); + + // ── Compact mode ─────────────────────────────────────────────────── + + it('applies compact text size', () => { + const { container } = renderStats({ compact: true }); + const statItems = container.querySelectorAll('.text-xs'); + expect(statItems.length).toBeGreaterThan(0); + }); + + it('applies normal text size when not compact', () => { + const { container } = renderStats({ compact: false }); + const statItems = container.querySelectorAll('.text-sm'); + expect(statItems.length).toBeGreaterThan(0); + }); + + // ── Icons ────────────────────────────────────────────────────────── + + it('renders icons when provided', () => { + const itemsWithIcons: StatItem[] = [ + { id: 'plays', label: 'Plays', value: 100, icon: StatIcons.plays }, + ]; + renderStats({ items: itemsWithIcons }); + const svg = screen.getByText('Plays:').parentElement?.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + it('does not render icon container when no icon provided', () => { + const noIconItems: StatItem[] = [ + { id: 'plays', label: 'Plays', value: 100 }, + ]; + renderStats({ items: noIconItems }); + const itemEl = screen.getByText('Plays:').parentElement; + expect(itemEl?.querySelector('svg')).toBeNull(); + }); + + // ── Predefined icons exist ───────────────────────────────────────── + + it('exports plays icon', () => { + expect(StatIcons.plays).toBeTruthy(); + }); + + it('exports views icon', () => { + expect(StatIcons.views).toBeTruthy(); + }); + + it('exports likes icon', () => { + expect(StatIcons.likes).toBeTruthy(); + }); + + it('exports comments icon', () => { + expect(StatIcons.comments).toBeTruthy(); + }); + + it('exports shares icon', () => { + expect(StatIcons.shares).toBeTruthy(); + }); + + it('exports downloads icon', () => { + expect(StatIcons.downloads).toBeTruthy(); + }); + + it('exports duration icon', () => { + expect(StatIcons.duration).toBeTruthy(); + }); + + it('exports calendar icon', () => { + expect(StatIcons.calendar).toBeTruthy(); + }); + + it('exports episodes icon', () => { + expect(StatIcons.episodes).toBeTruthy(); + }); + + it('exports subscribers icon', () => { + expect(StatIcons.subscribers).toBeTruthy(); + }); + + // ── Clickable items ──────────────────────────────────────────────── + + it('calls onClick when a clickable item is clicked', () => { + const onClick = vi.fn(); + const clickableItems: StatItem[] = [ + { id: 'stat', label: 'Stat', value: 42, onClick }, + ]; + renderStats({ items: clickableItems }); + fireEvent.click(screen.getByText('42')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + // ── Links ────────────────────────────────────────────────────────── + + it('renders item as a link when href is provided', () => { + const linkItems: StatItem[] = [ + { id: 'link', label: 'Link', value: 'Click me', href: 'https://example.com' }, + ]; + renderStats({ items: linkItems }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + // ── Tooltip ──────────────────────────────────────────────────────── + + it('renders tooltip as title attribute', () => { + const tooltipItems: StatItem[] = [ + { id: 'tip', label: 'Tip', value: 99, tooltip: 'Total plays' }, + ]; + renderStats({ items: tooltipItems }); + const itemEl = screen.getByText('99').closest('[title]'); + expect(itemEl).toHaveAttribute('title', 'Total plays'); + }); + + // ── className ────────────────────────────────────────────────────── + + it('passes custom className to the container', () => { + const { container } = renderStats({ className: 'my-stats' }); + expect(container.firstElementChild?.className).toContain('my-stats'); + }); +}); diff --git a/src/context/AdContext.test.tsx b/src/context/AdContext.test.tsx new file mode 100644 index 0000000..7624582 --- /dev/null +++ b/src/context/AdContext.test.tsx @@ -0,0 +1,1443 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, render } from '@testing-library/react'; +import { AdProvider, useAds } from './AdContext'; +import { createMockAd, createMockAdBreak } from '@/test/helpers'; +import type { AdConfig, Ad, AdBreak } from '@/types/ads'; + +// Mock fetch +const mockFetch = vi.fn(() => Promise.resolve(new Response())); + +beforeEach(() => { + vi.useFakeTimers(); + globalThis.fetch = mockFetch as unknown as typeof fetch; + mockFetch.mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const createWrapper = (config: Partial = {}) => { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('AdContext', () => { + describe('useAds hook', () => { + it('throws error when used outside provider', () => { + expect(() => { + renderHook(() => useAds()); + }).toThrow('useAds must be used within an AdProvider'); + }); + + it('returns context inside provider', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + expect(result.current.state).toBeDefined(); + expect(result.current.controls).toBeDefined(); + expect(result.current.config).toBeDefined(); + }); + }); + + describe('Initial state', () => { + it('is not playing ad initially', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.isPlayingAd).toBe(false); + }); + + it('has no current ad initially', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.currentAd).toBeNull(); + }); + + it('has no current ad break initially', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.currentAdBreak).toBeNull(); + }); + + it('has zero ad progress', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.adProgress).toBe(0); + }); + + it('has zero ad duration', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.adDuration).toBe(0); + }); + + it('cannot skip initially', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.canSkip).toBe(false); + }); + + it('has zero skip countdown', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.skipCountdown).toBe(0); + }); + + it('has zero ads remaining', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.state.adsRemaining).toBe(0); + }); + }); + + describe('Config defaults', () => { + it('defaults enabled to false', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.config.enabled).toBe(false); + }); + + it('defaults adBreaks to empty', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.config.adBreaks).toEqual([]); + }); + + it('defaults skipAllowed to true', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.config.skipAllowed).toBe(true); + }); + + it('defaults defaultSkipAfter to 5', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.config.defaultSkipAfter).toBe(5); + }); + + it('merges user config with defaults', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ + enabled: true, + defaultSkipAfter: 10, + }), + }); + + expect(result.current.config.enabled).toBe(true); + expect(result.current.config.defaultSkipAfter).toBe(10); + expect(result.current.config.skipAllowed).toBe(true); + }); + }); + + describe('Controls', () => { + it('has all ad controls', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper(), + }); + + expect(result.current.controls.skipAd).toBeDefined(); + expect(result.current.controls.clickThrough).toBeDefined(); + expect(result.current.controls.startAdBreak).toBeDefined(); + expect(result.current.controls.stopAds).toBeDefined(); + }); + }); + + describe('startAdBreak', () => { + it('starts playing the first ad in the break', () => { + const onAdStart = vi.fn(); + const ad = createMockAd(); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdStart }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.isPlayingAd).toBe(true); + expect(result.current.state.currentAd?.id).toBe(ad.id); + expect(result.current.state.currentAdBreak?.id).toBe(adBreak.id); + expect(result.current.state.adDuration).toBe(ad.duration); + expect(onAdStart).toHaveBeenCalledWith(ad, adBreak); + }); + + it('ignores empty ad breaks', () => { + const onAdStart = vi.fn(); + const adBreak = createMockAdBreak({ ads: [] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdStart }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.isPlayingAd).toBe(false); + expect(onAdStart).not.toHaveBeenCalled(); + }); + + it('sets ads remaining correctly', () => { + const ads = [ + createMockAd({ id: 'ad-1' }), + createMockAd({ id: 'ad-2' }), + createMockAd({ id: 'ad-3' }), + ]; + const adBreak = createMockAdBreak({ ads }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.adsRemaining).toBe(2); + }); + + it('resets ad progress to zero', () => { + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(createMockAdBreak()); + }); + + expect(result.current.state.adProgress).toBe(0); + }); + + it('fires impression tracking', () => { + const ad = createMockAd({ + trackingUrls: { + impression: 'https://example.com/impression', + start: 'https://example.com/start', + }, + }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/impression', expect.objectContaining({ + method: 'GET', + mode: 'no-cors', + })); + }); + + it('fires start tracking', () => { + const ad = createMockAd({ + trackingUrls: { + start: 'https://example.com/start', + }, + }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/start', expect.objectContaining({ + method: 'GET', + })); + }); + }); + + describe('Skip countdown', () => { + it('sets skipCountdown from ad skipAfterSeconds', () => { + const ad = createMockAd({ skipAfterSeconds: 5 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.skipCountdown).toBe(5); + expect(result.current.state.canSkip).toBe(false); + }); + + it('decrements countdown every second', () => { + const ad = createMockAd({ skipAfterSeconds: 3 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.state.skipCountdown).toBe(2); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.state.skipCountdown).toBe(1); + }); + + it('enables skip when countdown reaches zero', () => { + const ad = createMockAd({ skipAfterSeconds: 2 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.state.canSkip).toBe(true); + expect(result.current.state.skipCountdown).toBe(0); + }); + + it('immediately allows skip when skipAfterSeconds is 0', () => { + const ad = createMockAd({ skipAfterSeconds: 0 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.canSkip).toBe(true); + expect(result.current.state.skipCountdown).toBe(0); + }); + + it('uses defaultSkipAfter from config when ad has no skipAfterSeconds', () => { + const ad = createMockAd({ skipAfterSeconds: undefined }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, defaultSkipAfter: 7 }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.skipCountdown).toBe(7); + }); + + it('falls back to defaultSkipAfter when skipAfterSeconds is null', () => { + // In AdContext, null is treated as nullish and falls through to defaultSkipAfter + const ad = createMockAd({ skipAfterSeconds: null }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, defaultSkipAfter: 5 }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.canSkip).toBe(false); + expect(result.current.state.skipCountdown).toBe(5); + + // After countdown, skip is allowed + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current.state.canSkip).toBe(true); + }); + }); + + describe('skipAd', () => { + it('skips the current ad when allowed', () => { + const onAdSkip = vi.fn(); + const ad = createMockAd({ skipAfterSeconds: 0 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdSkip }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(onAdSkip).toHaveBeenCalledWith(ad, adBreak); + }); + + it('does not skip when canSkip is false', () => { + const onAdSkip = vi.fn(); + const ad = createMockAd({ skipAfterSeconds: 10 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdSkip }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(onAdSkip).not.toHaveBeenCalled(); + expect(result.current.state.isPlayingAd).toBe(true); + }); + + it('fires skip tracking URL', () => { + const ad = createMockAd({ + skipAfterSeconds: 0, + trackingUrls: { skip: 'https://example.com/skip' }, + }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + mockFetch.mockClear(); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/skip', expect.objectContaining({ + method: 'GET', + })); + }); + + it('advances to next ad in break when skipping', () => { + const ad1 = createMockAd({ id: 'ad-1', skipAfterSeconds: 0 }); + const ad2 = createMockAd({ id: 'ad-2', skipAfterSeconds: 0 }); + const adBreak = createMockAdBreak({ ads: [ad1, ad2] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.currentAd?.id).toBe('ad-1'); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(result.current.state.currentAd?.id).toBe('ad-2'); + expect(result.current.state.isPlayingAd).toBe(true); + }); + + it('ends ad break when skipping last ad', () => { + const onAllAdsComplete = vi.fn(); + const ad = createMockAd({ skipAfterSeconds: 0 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAllAdsComplete }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(result.current.state.isPlayingAd).toBe(false); + expect(result.current.state.currentAd).toBeNull(); + expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak); + }); + + it('does nothing when no ad is playing', () => { + const onAdSkip = vi.fn(); + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdSkip }), + }); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(onAdSkip).not.toHaveBeenCalled(); + }); + + it('clears skip timer when skipping after countdown completes', () => { + const onAdSkip = vi.fn(); + const ad = createMockAd({ skipAfterSeconds: 2 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdSkip }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + // Wait for countdown to finish (timer clears itself but ref still holds the ID) + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.state.canSkip).toBe(true); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(onAdSkip).toHaveBeenCalledWith(ad, adBreak); + }); + }); + + describe('clickThrough', () => { + it('opens click-through URL in new tab', () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const ad = createMockAd({ clickThroughUrl: 'https://advertiser.com' }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.clickThrough(); + }); + + expect(windowOpenSpy).toHaveBeenCalledWith('https://advertiser.com', '_blank'); + windowOpenSpy.mockRestore(); + }); + + it('fires click tracking URL', () => { + const ad = createMockAd({ + trackingUrls: { click: 'https://example.com/click' }, + }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + mockFetch.mockClear(); + + act(() => { + result.current.controls.clickThrough(); + }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/click', expect.objectContaining({ + method: 'GET', + })); + }); + + it('calls onAdClick callback', () => { + const onAdClick = vi.fn(); + const ad = createMockAd(); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdClick }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.clickThrough(); + }); + + expect(onAdClick).toHaveBeenCalledWith(ad, adBreak); + }); + + it('does nothing when no ad is playing', () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.clickThrough(); + }); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + windowOpenSpy.mockRestore(); + }); + + it('handles ad without clickThroughUrl', () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const ad = createMockAd({ clickThroughUrl: undefined }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.clickThrough(); + }); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + windowOpenSpy.mockRestore(); + }); + }); + + describe('stopAds', () => { + it('resets all ad state', () => { + const ad = createMockAd(); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.isPlayingAd).toBe(true); + + act(() => { + result.current.controls.stopAds(); + }); + + expect(result.current.state.isPlayingAd).toBe(false); + expect(result.current.state.currentAd).toBeNull(); + expect(result.current.state.currentAdBreak).toBeNull(); + expect(result.current.state.adProgress).toBe(0); + expect(result.current.state.adDuration).toBe(0); + expect(result.current.state.canSkip).toBe(false); + expect(result.current.state.skipCountdown).toBe(0); + expect(result.current.state.adsRemaining).toBe(0); + }); + + it('clears skip countdown timer', () => { + const ad = createMockAd({ skipAfterSeconds: 10 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.stopAds(); + }); + + // Advance time - countdown should not continue + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current.state.skipCountdown).toBe(0); + }); + }); + + describe('Multiple ads in break', () => { + it('starts with first ad', () => { + const ads = [ + createMockAd({ id: 'ad-1', duration: 10 }), + createMockAd({ id: 'ad-2', duration: 20 }), + createMockAd({ id: 'ad-3', duration: 15 }), + ]; + const adBreak = createMockAdBreak({ ads }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.currentAd?.id).toBe('ad-1'); + expect(result.current.state.adsRemaining).toBe(2); + }); + + it('tracks remaining ads count after skip', () => { + const ads = [ + createMockAd({ id: 'ad-1', skipAfterSeconds: 0 }), + createMockAd({ id: 'ad-2', skipAfterSeconds: 0 }), + createMockAd({ id: 'ad-3', skipAfterSeconds: 0 }), + ]; + const adBreak = createMockAdBreak({ ads }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(result.current.state.adsRemaining).toBe(2); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(result.current.state.currentAd?.id).toBe('ad-2'); + expect(result.current.state.adsRemaining).toBe(1); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(result.current.state.currentAd?.id).toBe('ad-3'); + expect(result.current.state.adsRemaining).toBe(0); + }); + }); + + describe('Ad lifecycle callbacks', () => { + it('calls onAdStart when ad begins', () => { + const onAdStart = vi.fn(); + const ad = createMockAd(); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAdStart }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(onAdStart).toHaveBeenCalledWith(ad, adBreak); + }); + + it('calls onAllAdsComplete when all ads in break finish', () => { + const onAllAdsComplete = vi.fn(); + const ad = createMockAd({ skipAfterSeconds: 0 }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true, onAllAdsComplete }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + act(() => { + result.current.controls.skipAd(); + }); + + expect(onAllAdsComplete).toHaveBeenCalledWith(adBreak); + }); + }); + + describe('Tracking URLs', () => { + it('does not call fetch when no tracking URLs are configured', () => { + const ad = createMockAd({ trackingUrls: undefined }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + mockFetch.mockClear(); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('handles fetch errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const ad = createMockAd({ + trackingUrls: { impression: 'https://example.com/impression' }, + }); + const adBreak = createMockAdBreak({ ads: [ad] }); + + const { result } = renderHook(() => useAds(), { + wrapper: createWrapper({ enabled: true }), + }); + + act(() => { + result.current.controls.startAdBreak(adBreak); + }); + + // Should not throw + expect(result.current.state.isPlayingAd).toBe(true); + consoleSpy.mockRestore(); + }); + }); + + // ============================================================= + // Audio element event handler tests + // ============================================================= + + describe('Audio element event handlers', () => { + /** + * Helper: renders the AdProvider, starts an ad break, and returns + * the internal