From a9e12024c59d887d3190127a1cbb36c8657ddd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20N=C3=A9d=C3=A9lec?= <10513639+pierrenedelec@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:52:45 +0100 Subject: [PATCH] test(frontend): unit-test files and github actions --- .github/workflows/test.yml | 44 +++ ui/angular.json | 5 +- ui/package.json | 4 + ui/pnpm-lock.yaml | 224 +++++++++++++ .../master-checkbox.component.spec.ts | 34 ++ .../slave-checkbox.component.spec.ts | 80 +++++ ui/src/app/pipes/eta.pipe.spec.ts | 52 +++ ui/src/app/pipes/eta.pipe.ts | 2 +- ui/src/app/pipes/file-size.pipe.spec.ts | 57 ++++ ui/src/app/pipes/speed.pipe.ts | 2 +- ui/src/app/services/downloads.service.spec.ts | 306 ++++++++++++++++++ ui/src/app/services/speed.service.spec.ts | 81 +++++ 12 files changed, 888 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 ui/src/app/components/master-checkbox.component.spec.ts create mode 100644 ui/src/app/components/slave-checkbox.component.spec.ts create mode 100644 ui/src/app/pipes/eta.pipe.spec.ts create mode 100644 ui/src/app/pipes/file-size.pipe.spec.ts create mode 100644 ui/src/app/services/downloads.service.spec.ts create mode 100644 ui/src/app/services/speed.service.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2dbbaf9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Run FE unit tests + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + +jobs: + frontend-tests: + name: Frontend Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install pnpm and run install + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + working-directory: ui + run_install: true + + - name: Generate coverage report + working-directory: ui + run: pnpm test:coverage + continue-on-error: true + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: success() + with: + directory: ui/coverage + flags: frontend + fail_ci_if_error: false + continue-on-error: true diff --git a/ui/angular.json b/ui/angular.json index 229711e..87e2a12 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -83,7 +83,10 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "coverageExclude": ["**/index.ts", "src/app/interfaces/*.ts", "src/app/theme.ts"] + } }, "lint": { "builder": "@angular-eslint/builder:lint", diff --git a/ui/package.json b/ui/package.json index 3c87629..ce3f771 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,6 +7,7 @@ "build": "ng build", "build:watch": "ng build --watch", "test": "ng test", + "test:coverage": "ng test --coverage", "lint": "ng lint" }, "prettier": { @@ -52,6 +53,9 @@ "@angular/compiler-cli": "^21.0.0", "@angular/localize": "^21.0.0", "@eslint/js": "^9.39.1", + "@testing-library/angular": "^18.1.1", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "^4.0.15", "angular-eslint": "21.1.0", "eslint": "^9.39.1", "jsdom": "^27.1.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index f0b04f5..3fb2dc3 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -90,6 +90,15 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.2 + '@testing-library/angular': + specifier: ^18.1.1 + version: 18.1.1(d594df7ad7bf2a0a81d88b699d7cab95) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@vitest/coverage-v8': + specifier: ^4.0.15 + version: 4.0.15(vitest@4.0.15(@types/node@25.0.1)(jsdom@27.3.0(postcss@8.5.6))(sass@1.93.2)) angular-eslint: specifier: 21.1.0 version: 21.1.0(@angular/cli@21.0.3(@types/node@25.0.1)(chokidar@4.0.3))(chokidar@4.0.3)(eslint@9.39.2)(typescript-eslint@8.47.0(eslint@9.39.2)(typescript@5.9.3))(typescript@5.9.3) @@ -365,6 +374,15 @@ packages: '@angular/animations': optional: true + '@angular/router@21.0.5': + resolution: {integrity: sha512-IFmf0Wd7jSOoZ8TI+4RXMsYmnIfHQG+kGxeMQVKrefTdr3uEHW/TEsNzbW5bkCpVJHRm4EhkH4hSu8D8tUQffQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.5 + '@angular/core': 21.0.5 + '@angular/platform-browser': 21.0.5 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/service-worker@21.0.5': resolution: {integrity: sha512-Jh7e4XOpQUWsLL3IB1He/2ZPTyULZwRdfneU5rrG2PjGlGus2PrLrDQ2lVlEZyQwAGPk1eyabcDoZKXIJ0uVig==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -445,6 +463,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -457,6 +479,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1646,6 +1672,25 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@testing-library/angular@18.1.1': + resolution: {integrity: sha512-LbA+W+VeOf7TC7/ZfHLiOLlLyD2cVG3mBdkJapviC2Fd4Bw/Utcaso4bh+5B0cx/fyKyuPgS+L6FnaKGdP9HBA==} + peerDependencies: + '@angular/common': '>= 20.0.0' + '@angular/core': '>= 20.0.0' + '@angular/platform-browser': '>= 20.0.0' + '@angular/router': '>= 20.0.0' + '@testing-library/dom': ^10.0.0 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -1657,6 +1702,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1789,6 +1837,15 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.0.15': + resolution: {integrity: sha512-FUJ+1RkpTFW7rQITdgTi93qOCWJobWhBirEPCeXh2SW2wsTlFxy51apDz5gzG+ZEYt/THvWeNmhdAoS9DTwpCw==} + peerDependencies: + '@vitest/browser': 4.0.15 + vitest: 4.0.15 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.15': resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} @@ -1889,6 +1946,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1896,6 +1957,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1904,6 +1968,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.8: + resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2115,6 +2182,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -2124,6 +2195,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2447,6 +2521,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} @@ -2568,12 +2645,27 @@ packages: resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} engines: {node: '>=10'} + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2657,12 +2749,23 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-fetch-happen@15.0.3: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2976,6 +3079,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3011,6 +3118,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3960,6 +4070,14 @@ snapshots: optionalDependencies: '@angular/animations': 21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/router@21.0.5(@angular/common@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.5(@angular/animations@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.5(@angular/animations@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)) + rxjs: 7.8.2 + tslib: 2.8.1 + '@angular/service-worker@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': dependencies: '@angular/core': 21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0) @@ -4069,6 +4187,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4092,6 +4212,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -4990,6 +5112,30 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@testing-library/angular@18.1.1(d594df7ad7bf2a0a81d88b699d7cab95)': + dependencies: + '@angular/common': 21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.5(@angular/animations@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/router': 21.0.5(@angular/common@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.5(@angular/animations@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.5(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.5(@angular/compiler@21.0.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@testing-library/dom': 10.4.1 + tslib: 2.8.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@4.0.0': @@ -5002,6 +5148,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -5190,6 +5338,23 @@ snapshots: dependencies: vite: 7.2.2(@types/node@25.0.1)(sass@1.93.2) + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@25.0.1)(jsdom@27.3.0(postcss@8.5.6))(sass@1.93.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.15 + ast-v8-to-istanbul: 0.3.8 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.15(@types/node@25.0.1)(jsdom@27.3.0(postcss@8.5.6))(sass@1.93.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -5317,14 +5482,26 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.8: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -5534,12 +5711,16 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@1.0.3: optional: true detect-libc@2.1.2: optional: true + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5951,6 +6132,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + htmlparser2@10.0.0: dependencies: domelementtype: 2.3.0 @@ -6059,10 +6242,31 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jose@6.1.3: {} js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -6173,6 +6377,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6181,6 +6387,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + make-fetch-happen@15.0.3: dependencies: '@npmcli/agent': 4.0.0 @@ -6535,6 +6751,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + proc-log@5.0.0: {} proc-log@6.1.0: {} @@ -6566,6 +6788,8 @@ snapshots: iconv-lite: 0.7.1 unpipe: 1.0.0 + react-is@17.0.2: {} + readdirp@4.1.2: {} reflect-metadata@0.2.2: {} diff --git a/ui/src/app/components/master-checkbox.component.spec.ts b/ui/src/app/components/master-checkbox.component.spec.ts new file mode 100644 index 0000000..c10b936 --- /dev/null +++ b/ui/src/app/components/master-checkbox.component.spec.ts @@ -0,0 +1,34 @@ +/* eslint-disable @angular-eslint/use-component-selector */ +import { render, screen } from '@testing-library/angular'; +import { MasterCheckboxComponent } from './master-checkbox.component'; +import { Checkable } from '../interfaces'; + +describe('MasterCheckboxComponent', () => { + async function createMasterCheckboxComponent( + override: { template?: string; componentProperties?: Record } = {}, + ) { + override.template ||= ` + + `; + + const rendered = await render(override.template, { + imports: [MasterCheckboxComponent], + componentProperties: { + doneList: new Map([ + ['url1', { checked: false }], + ['url2', { checked: false }], + ]), + doneSelectionChanged: (event: Event) => { console.log(event)}, + ...override.componentProperties, + }, + }); + + return rendered; + } + + it('should create the component', async () => { + const { fixture } = await createMasterCheckboxComponent(); + expect(fixture.componentInstance).toBeTruthy(); + expect(screen.getByRole('checkbox')).toBeDefined(); + }); +}); diff --git a/ui/src/app/components/slave-checkbox.component.spec.ts b/ui/src/app/components/slave-checkbox.component.spec.ts new file mode 100644 index 0000000..44e4110 --- /dev/null +++ b/ui/src/app/components/slave-checkbox.component.spec.ts @@ -0,0 +1,80 @@ +/* eslint-disable @angular-eslint/use-component-selector */ +import { render, screen } from '@testing-library/angular'; +import { SlaveCheckboxComponent } from './slave-checkbox.component'; +import { MasterCheckboxComponent } from './master-checkbox.component'; + +describe('SlaveCheckboxComponent', () => { + async function createSlaveCheckboxComponent( + override: { template?: string; componentProperties?: Record } = {}, + ) { + override.template ||= ` + + `; + + const mockMasterCheckbox = { + id: () => 'master', + selectionChanged: vi.fn(), + } as unknown as MasterCheckboxComponent; + + const rendered = await render(override.template, { + imports: [SlaveCheckboxComponent], + componentProperties: { + masterCheckbox: mockMasterCheckbox, + checkable: { checked: false }, + ...override.componentProperties, + }, + }); + + return rendered; + } + + it('should create the component', async () => { + const { fixture } = await createSlaveCheckboxComponent(); + expect(fixture.componentInstance).toBeTruthy(); + expect(screen.getByRole('checkbox')).toBeDefined(); + }); + + it('should render unchecked checkbox by default', async () => { + await createSlaveCheckboxComponent(); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it('should render checked checkbox when checkable.checked is true', async () => { + await createSlaveCheckboxComponent({ + componentProperties: { + checkable: { checked: true }, + }, + }); + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('should have correct id attribute', async () => { + const mockMasterCheckbox = { + id: () => 'queue', + selectionChanged: vi.fn(), + } as unknown as MasterCheckboxComponent; + + await createSlaveCheckboxComponent({ + componentProperties: { + masterCheckbox: mockMasterCheckbox, + checkable: { checked: false }, + }, + template: ` + + `, + }); + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.id).toBe('queue-url123-select'); + }); +}); diff --git a/ui/src/app/pipes/eta.pipe.spec.ts b/ui/src/app/pipes/eta.pipe.spec.ts new file mode 100644 index 0000000..a7cb2d7 --- /dev/null +++ b/ui/src/app/pipes/eta.pipe.spec.ts @@ -0,0 +1,52 @@ +import { EtaPipe } from './eta.pipe'; + +describe('EtaPipe', () => { + let pipe: EtaPipe; + + beforeEach(() => { + pipe = new EtaPipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return null for null input', () => { + expect(pipe.transform(null)).toBeNull(); + }); + + it('should format seconds less than 60', () => { + expect(pipe.transform(0)).toBe('0s'); + expect(pipe.transform(30)).toBe('30s'); + expect(pipe.transform(59)).toBe('59s'); + expect(pipe.transform(59.4)).toBe('59s'); + expect(pipe.transform(59.6)).toBe('60s'); + }); + + it('should format time between 60 seconds and 1 hour', () => { + expect(pipe.transform(60)).toBe('1m 0s'); + expect(pipe.transform(90)).toBe('1m 30s'); + expect(pipe.transform(150)).toBe('2m 30s'); + expect(pipe.transform(3599)).toBe('59m 59s'); + }); + + it('should format time over 1 hour', () => { + expect(pipe.transform(3600)).toBe('1h 0m 0s'); + expect(pipe.transform(3661)).toBe('1h 1m 1s'); + expect(pipe.transform(7200)).toBe('2h 0m 0s'); + expect(pipe.transform(7323)).toBe('2h 2m 3s'); + expect(pipe.transform(36000)).toBe('10h 0m 0s'); + }); + + it('should handle large time values', () => { + expect(pipe.transform(86400)).toBe('24h 0m 0s'); // 1 day + expect(pipe.transform(90061)).toBe('25h 1m 1s'); // 25 hours, 1 minute, 1 second + }); + + it('should round seconds appropriately', () => { + expect(pipe.transform(59.4)).toBe('59s'); + expect(pipe.transform(59.6)).toBe('60s'); + expect(pipe.transform(3600.4)).toBe('1h 0m 0s'); + expect(pipe.transform(3600.6)).toBe('1h 0m 1s'); + }); +}); diff --git a/ui/src/app/pipes/eta.pipe.ts b/ui/src/app/pipes/eta.pipe.ts index 9135308..7b56bc8 100644 --- a/ui/src/app/pipes/eta.pipe.ts +++ b/ui/src/app/pipes/eta.pipe.ts @@ -4,7 +4,7 @@ import { Pipe, PipeTransform } from "@angular/core"; name: 'eta', }) export class EtaPipe implements PipeTransform { - transform(value: number): string | null { + transform(value: number | null): string | null { if (value === null) { return null; } diff --git a/ui/src/app/pipes/file-size.pipe.spec.ts b/ui/src/app/pipes/file-size.pipe.spec.ts new file mode 100644 index 0000000..4466078 --- /dev/null +++ b/ui/src/app/pipes/file-size.pipe.spec.ts @@ -0,0 +1,57 @@ +import { FileSizePipe } from './file-size.pipe'; + +describe('FileSizePipe', () => { + let pipe: FileSizePipe; + + beforeEach(() => { + pipe = new FileSizePipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return "0 Bytes" for zero or invalid input', () => { + expect(pipe.transform(0)).toBe('0 Bytes'); + expect(pipe.transform(NaN)).toBe('0 Bytes'); + }); + + it('should format bytes correctly', () => { + expect(pipe.transform(500)).toBe('500.00 Bytes'); + expect(pipe.transform(999)).toBe('999.00 Bytes'); + }); + + it('should format kilobytes correctly', () => { + expect(pipe.transform(1000)).toBe('1.00 KB'); + expect(pipe.transform(1500)).toBe('1.50 KB'); + expect(pipe.transform(999999)).toBe('1000.00 KB'); + }); + + it('should format megabytes correctly', () => { + expect(pipe.transform(1000000)).toBe('1.00 MB'); + expect(pipe.transform(1500000)).toBe('1.50 MB'); + expect(pipe.transform(52428800)).toBe('52.43 MB'); // 50 MB in binary, ~52.43 MB in decimal + }); + + it('should format gigabytes correctly', () => { + expect(pipe.transform(1000000000)).toBe('1.00 GB'); + expect(pipe.transform(1500000000)).toBe('1.50 GB'); + expect(pipe.transform(5368709120)).toBe('5.37 GB'); // 5 GB in binary, ~5.37 GB in decimal + }); + + it('should format terabytes correctly', () => { + expect(pipe.transform(1000000000000)).toBe('1.00 TB'); + expect(pipe.transform(1500000000000)).toBe('1.50 TB'); + }); + + it('should format very large sizes correctly', () => { + expect(pipe.transform(1000000000000000)).toBe('1.00 PB'); // Petabyte + expect(pipe.transform(1000000000000000000)).toBe('1.00 EB'); // Exabyte + }); + + it('should always show 2 decimal places', () => { + expect(pipe.transform(1234567)).toBe('1.23 MB'); + expect(pipe.transform(1234567890)).toBe('1.23 GB'); + expect(pipe.transform(1234)).toBe('1.23 KB'); + }); +}); diff --git a/ui/src/app/pipes/speed.pipe.ts b/ui/src/app/pipes/speed.pipe.ts index 6a2ef9b..2fb7c4e 100644 --- a/ui/src/app/pipes/speed.pipe.ts +++ b/ui/src/app/pipes/speed.pipe.ts @@ -28,7 +28,7 @@ export class SpeedPipe implements PipeTransform { }); } - transform(value: number): string { + transform(value: number | null | undefined): string { // If speed is invalid or 0, return empty string if (value === null || value === undefined || isNaN(value) || value <= 0) { return ''; diff --git a/ui/src/app/services/downloads.service.spec.ts b/ui/src/app/services/downloads.service.spec.ts new file mode 100644 index 0000000..fbbed21 --- /dev/null +++ b/ui/src/app/services/downloads.service.spec.ts @@ -0,0 +1,306 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { DownloadsService } from './downloads.service'; +import { MeTubeSocket } from './metube-socket.service'; +import { Download, Status } from '../interfaces'; +import { of, Observable } from 'rxjs'; + +describe('DownloadsService', () => { + let service: DownloadsService; + let httpMock: HttpTestingController; + let mockSocket: any; + + beforeEach(() => { + // Create mock socket with proper typing + const fromEventMock = (event: string): Observable => { + // Return properly formatted data for different events + if (event === 'all') { + return of('[[],[]]' as any); // Empty queue and done arrays + } else if (event === 'configuration') { + return of('{}' as any); // Empty configuration object + } else if (event === 'custom_dirs') { + return of('{}' as any); // Empty custom dirs object + } + return of('[]' as any); // Default for other events + }; + + mockSocket = { + fromEvent: fromEventMock, + }; + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + DownloadsService, + { provide: MeTubeSocket, useValue: mockSocket }, + ], + }); + + service = TestBed.inject(DownloadsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should have queue and done maps initialized', () => { + // The service initializes with socket events, so we just verify the maps exist + expect(service.queue).toBeInstanceOf(Map); + expect(service.done).toBeInstanceOf(Map); + expect(service.loading).toBe(false); + }); + + it('should add a download via HTTP POST', () => { + const mockStatus: Status = { status: 'success' }; + const url = 'https://example.com/video'; + const quality = 'best'; + const format = 'mp4'; + + service.add(url, quality, format, '', '', false, 0, true).subscribe((status) => { + expect(status).toEqual(mockStatus); + }); + + const req = httpMock.expectOne('add'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + url, + quality, + format, + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + auto_start: true, + }); + req.flush(mockStatus); + }); + + it('should start downloads by id', () => { + const ids = ['url1', 'url2']; + + service.startById(ids).subscribe(); + + const req = httpMock.expectOne('start'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ ids }); + req.flush({}); + }); + + it('should delete downloads by id and mark them as deleting', () => { + const mockDownload: Download = { + id: 'id1', + title: 'Test', + url: 'url1', + quality: 'best', + format: 'mp4', + status: 'finished', + msg: '', + filename: 'test.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 100, + speed: 0, + eta: 0, + checked: false, + }; + service.done.set('url1', mockDownload); + + const ids = ['url1']; + service.delById('done', ids).subscribe(); + + expect(mockDownload.deleting).toBe(true); + + const req = httpMock.expectOne('delete'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ where: 'done', ids }); + req.flush({}); + }); + + it('should start downloads by filter', () => { + const mockDownload1: Download = { + id: 'id1', + title: 'Test 1', + url: 'url1', + quality: 'best', + format: 'mp4', + status: 'pending', + msg: '', + filename: 'test1.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 0, + speed: 0, + eta: 0, + checked: false, + }; + const mockDownload2: Download = { + id: 'id2', + title: 'Test 2', + url: 'url2', + quality: 'best', + format: 'mp4', + status: 'downloading', + msg: '', + filename: 'test2.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 50, + speed: 1000, + eta: 10, + checked: false, + }; + + service.queue.set('url1', mockDownload1); + service.queue.set('url2', mockDownload2); + + service.startByFilter('queue', (dl) => dl.status === 'pending').subscribe(); + + const req = httpMock.expectOne('start'); + expect(req.request.body.ids).toEqual(['url1']); + req.flush({}); + }); + + it('should delete downloads by filter', () => { + const mockDownload1: Download = { + id: 'id1', + title: 'Test 1', + url: 'url1', + quality: 'best', + format: 'mp4', + status: 'finished', + msg: '', + filename: 'test1.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 100, + speed: 0, + eta: 0, + checked: false, + }; + const mockDownload2: Download = { + id: 'id2', + title: 'Test 2', + url: 'url2', + quality: 'best', + format: 'mp4', + status: 'error', + msg: 'Failed', + filename: 'test2.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 0, + speed: 0, + eta: 0, + checked: false, + }; + + service.done.set('url1', mockDownload1); + service.done.set('url2', mockDownload2); + + service.delByFilter('done', (dl) => dl.status === 'error').subscribe(); + + const req = httpMock.expectOne('delete'); + expect(req.request.body.ids).toEqual(['url2']); + req.flush({}); + }); + + it('should handle HTTP errors', () => { + const url = 'https://example.com/video'; + + service.add(url, 'best', 'mp4', '', '', false, 0, true).subscribe((response) => { + expect(response.status).toBe('error'); + }); + + const req = httpMock.expectOne('add'); + req.error(new ProgressEvent('error'), { + status: 500, + statusText: 'Server Error', + }); + }); + + it('should add download by URL with default values', async () => { + const url = 'https://example.com/video'; + const mockStatus: Status = { status: 'success' }; + + const promise = service.addDownloadByUrl(url); + + const req = httpMock.expectOne('add'); + expect(req.request.body).toEqual({ + url, + quality: 'best', + format: 'mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + auto_start: true, + }); + req.flush(mockStatus); + + const result = await promise; + expect(result).toEqual(mockStatus); + }); + + it('should export queue URLs', () => { + const mockDownload1: Download = { + id: 'id1', + title: 'Test 1', + url: 'https://example.com/video1', + quality: 'best', + format: 'mp4', + status: 'pending', + msg: '', + filename: 'test1.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 0, + speed: 0, + eta: 0, + checked: false, + }; + const mockDownload2: Download = { + id: 'id2', + title: 'Test 2', + url: 'https://example.com/video2', + quality: 'best', + format: 'mp4', + status: 'downloading', + msg: '', + filename: 'test2.mp4', + folder: '', + custom_name_prefix: '', + playlist_strict_mode: false, + playlist_item_limit: 0, + percent: 50, + speed: 1000, + eta: 10, + checked: false, + }; + + service.queue.set('url1', mockDownload1); + service.queue.set('url2', mockDownload2); + + const urls = service.exportQueueUrls(); + expect(urls).toEqual(['https://example.com/video1', 'https://example.com/video2']); + }); +}); diff --git a/ui/src/app/services/speed.service.spec.ts b/ui/src/app/services/speed.service.spec.ts new file mode 100644 index 0000000..f64d627 --- /dev/null +++ b/ui/src/app/services/speed.service.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { SpeedService } from './speed.service'; + +describe('SpeedService', () => { + let service: SpeedService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [SpeedService], + }); + service = TestBed.inject(SpeedService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return 0 for mean speed when no measurements are added', () => { + const currentSpeed = service.getCurrentMeanSpeed(); + expect(currentSpeed).toBe(0); + }); + + it('should add speed measurement and calculate mean', () => { + service.addSpeedMeasurement(100); + service.addSpeedMeasurement(200); + service.addSpeedMeasurement(300); + + const meanSpeed = service.getCurrentMeanSpeed(); + expect(meanSpeed).toBe(200); // (100 + 200 + 300) / 3 = 200 + }); + + it('should maintain buffer size of 10 measurements', () => { + // Add 15 measurements + for (let i = 1; i <= 15; i++) { + service.addSpeedMeasurement(i * 10); + } + + // Should only keep the last 10: 60, 70, 80, 90, 100, 110, 120, 130, 140, 150 + const meanSpeed = service.getCurrentMeanSpeed(); + const expected = (60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + 140 + 150) / 10; + expect(meanSpeed).toBe(expected); + }); + + it('should provide a mean speed observable', () => { + service.addSpeedMeasurement(100); + service.addSpeedMeasurement(200); + + // Verify the observable exists and is defined + expect(service.meanSpeed$).toBeDefined(); + + // We can't easily test the interval-based observable in a unit test + // without complex zone/fakeAsync setup, so we verify behavior through + // the getCurrentMeanSpeed() method instead + const currentMean = service.getCurrentMeanSpeed(); + expect(currentMean).toBe(150); // (100 + 200) / 2 = 150 + }); + + it('should handle single measurement', () => { + service.addSpeedMeasurement(500); + const meanSpeed = service.getCurrentMeanSpeed(); + expect(meanSpeed).toBe(500); + }); + + it('should handle zero speeds', () => { + service.addSpeedMeasurement(0); + service.addSpeedMeasurement(0); + const meanSpeed = service.getCurrentMeanSpeed(); + expect(meanSpeed).toBe(0); + }); + + it('should update mean speed after new measurements', () => { + service.addSpeedMeasurement(100); + expect(service.getCurrentMeanSpeed()).toBe(100); + + service.addSpeedMeasurement(300); + expect(service.getCurrentMeanSpeed()).toBe(200); + + service.addSpeedMeasurement(600); + expect(service.getCurrentMeanSpeed()).toBe(333.3333333333333); + }); +});