test(frontend): unit-test files and github actions

This commit is contained in:
Pierre Nédélec 2025-12-15 23:52:45 +01:00
parent 23c3db0613
commit a9e12024c5
12 changed files with 888 additions and 3 deletions

44
.github/workflows/test.yml vendored Normal file
View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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: {}

View file

@ -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<string, unknown> } = {},
) {
override.template ||= `
<app-master-checkbox [id]="'done'" [list]="doneList" (changed)="doneSelectionChanged($event)" />
`;
const rendered = await render(override.template, {
imports: [MasterCheckboxComponent],
componentProperties: {
doneList: new Map<string, Checkable>([
['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();
});
});

View file

@ -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<string, unknown> } = {},
) {
override.template ||= `
<app-slave-checkbox
[id]="'item1'"
[master]="masterCheckbox"
[checkable]="checkable"
/>
`;
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: `
<app-slave-checkbox
[id]="'url123'"
[master]="masterCheckbox"
[checkable]="checkable"
/>
`,
});
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox.id).toBe('queue-url123-select');
});
});

View file

@ -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');
});
});

View file

@ -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;
}

View file

@ -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');
});
});

View file

@ -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 '';

View file

@ -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 = <T = any>(event: string): Observable<T> => {
// 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']);
});
});

View file

@ -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);
});
});