mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
206
Commits
runner-test
...
3.20.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ed3408ee7 | ||
|
|
f5da3920d2 | ||
|
|
e75a521284 | ||
|
|
d25e950d8a | ||
|
|
cc1ad91b58 | ||
|
|
ff84e574d6 | ||
|
|
0832dbfe67 | ||
|
|
62bb09e0b5 | ||
|
|
7c5148646c | ||
|
|
6724ec6406 | ||
|
|
82cfb8ece4 | ||
|
|
c23db2cd89 | ||
|
|
2d2344e0c5 | ||
|
|
c3b8a8e224 | ||
|
|
4e9358e69e | ||
|
|
ecaafd3c89 | ||
|
|
a30b78f74d | ||
|
|
1666cad7cc | ||
|
|
35f500af0f | ||
|
|
ce04b19145 | ||
|
|
701209ed4c | ||
|
|
f6cc05aa5d | ||
|
|
f42bc4e084 | ||
|
|
e37ec1f9d7 | ||
|
|
56fda867c2 | ||
|
|
58f2d3068e | ||
|
|
4dca1ab199 | ||
|
|
a3da0313b4 | ||
|
|
29f38ff0cc | ||
|
|
836628a22f | ||
|
|
0d0169bd87 | ||
|
|
2132a6bd70 | ||
|
|
2eeb9b0bd0 | ||
|
|
25c14aac0f | ||
|
|
c7cdb60d1d | ||
|
|
06659916d3 | ||
|
|
0871ca9b91 | ||
|
|
1b87eb02e8 | ||
|
|
6f53e9cb78 | ||
|
|
5054468710 | ||
|
|
65b69e0a92 | ||
|
|
8215747238 | ||
|
|
4d9ae22248 | ||
|
|
a5c617a9a7 | ||
|
|
2c5347fe15 | ||
|
|
a6ebad58a7 | ||
|
|
885611f00f | ||
|
|
4587d66ac2 | ||
|
|
d73f3b5101 | ||
|
|
944bde4295 | ||
|
|
a47aed615e | ||
|
|
44ca967cdb | ||
|
|
90aaf3686f | ||
|
|
91ae0f37a3 | ||
|
|
24a6522757 | ||
|
|
08fc18aab5 | ||
|
|
fca3c6d1c5 | ||
|
|
b0db467c64 | ||
|
|
ccc811d55b | ||
|
|
a070df939b | ||
|
|
77e6c7f554 | ||
|
|
312f817694 | ||
|
|
b95fa9b7ed | ||
|
|
17cdcc0800 | ||
|
|
d0b85c0722 | ||
|
|
42d8c529a3 | ||
|
|
6dd9662276 | ||
|
|
44bebc6fc3 | ||
|
|
e9140af10d | ||
|
|
773998c9b9 | ||
|
|
e37acc2d81 | ||
|
|
5964247bd6 | ||
|
|
0a0b9641b5 | ||
|
|
8851c68fc0 | ||
|
|
206226590d | ||
|
|
15bcc7b30d | ||
|
|
1c845dd967 | ||
|
|
97fbed26a9 | ||
|
|
349948ed52 | ||
|
|
2983c7ae15 | ||
|
|
58bbf10e60 | ||
|
|
8e4bf19b1d | ||
|
|
5b12edadc9 | ||
|
|
e8e4d095b6 | ||
|
|
6115357a7c | ||
|
|
2672ac8b63 | ||
|
|
b9c536c99b | ||
|
|
1e350ed4be | ||
|
|
75a65693d4 | ||
|
|
4b03769a50 | ||
|
|
8b51804962 | ||
|
|
092def09ea | ||
|
|
b9667dbd12 | ||
|
|
301085b566 | ||
|
|
22a4ae17f6 | ||
|
|
dc062cd349 | ||
|
|
513981674a | ||
|
|
771cf5ef81 | ||
|
|
13fb1e11ba | ||
|
|
caa28c2b19 | ||
|
|
a8a1c06c2e | ||
|
|
3cecd7d20b | ||
|
|
ed5cb01c70 | ||
|
|
433ec96264 | ||
|
|
40be964e71 | ||
|
|
b1ee1d206f | ||
|
|
97099ebb5d | ||
|
|
a728f86927 | ||
|
|
e916e8b378 | ||
|
|
e7a8e09ed4 | ||
|
|
035ca8f1d9 | ||
|
|
7cc9151e3b | ||
|
|
9ee5e51a6a | ||
|
|
f38bdca897 | ||
|
|
efb980afac | ||
|
|
7644990f91 | ||
|
|
41c4686a8c | ||
|
|
ffa8a44faf | ||
|
|
ee1a930f51 | ||
|
|
25da52b1dd | ||
|
|
0326b21a5b | ||
|
|
3e4a1cc941 | ||
|
|
3b629534a4 | ||
|
|
4fa7fb1a10 | ||
|
|
6e2c66045c | ||
|
|
6fc614f679 | ||
|
|
c41f091869 | ||
|
|
c6b598de25 | ||
|
|
4d5f6b07ab | ||
|
|
e79cc5f4ab | ||
|
|
8091d21405 | ||
|
|
7ac1541bcb | ||
|
|
71ba58c83e | ||
|
|
9d2df861ba | ||
|
|
9b2708e989 | ||
|
|
24fe973467 | ||
|
|
4522e9aeba | ||
|
|
5ce1679462 | ||
|
|
bde86054c7 | ||
|
|
5dfff69177 | ||
|
|
349f296285 | ||
|
|
f4e822aafc | ||
|
|
41ede1d221 | ||
|
|
110e9d47bc | ||
|
|
4275a854bd | ||
|
|
1f83e06dee | ||
|
|
bb6e9d3317 | ||
|
|
af3ea08294 | ||
|
|
7ea227078a | ||
|
|
1c9e481dcc | ||
|
|
b8dbc8658f | ||
|
|
f78b8420ed | ||
|
|
b3d2e2e6a8 | ||
|
|
482f6840e0 | ||
|
|
b3570c72ca | ||
|
|
eba0fc3669 | ||
|
|
a4984a9f58 | ||
|
|
4fc3d1e1d7 | ||
|
|
feb3c43e0b | ||
|
|
eb9e7b7260 | ||
|
|
c1472da8f5 | ||
|
|
199f17999b | ||
|
|
8cb7a33b14 | ||
|
|
bfffe247d4 | ||
|
|
964a9b458b | ||
|
|
ea525a0921 | ||
|
|
d81b0abbea | ||
|
|
207001398b | ||
|
|
4db4e9f4ff | ||
|
|
4c5391bfe1 | ||
|
|
7d3176fdd6 | ||
|
|
e15e941fde | ||
|
|
eb8d0bc2c9 | ||
|
|
560322baa8 | ||
|
|
63c289adb4 | ||
|
|
8043dca022 | ||
|
|
0bd97e11d0 | ||
|
|
edd62ee7f6 | ||
|
|
8ce8382511 | ||
|
|
9fcc5eb950 | ||
|
|
3258ccf670 | ||
|
|
72c43de518 | ||
|
|
921eb894ba | ||
|
|
26a85eba71 | ||
|
|
e4ad5ba606 | ||
|
|
44468584df | ||
|
|
dccad4529f | ||
|
|
080d131053 | ||
|
|
c8bf135880 | ||
|
|
a837bb778e | ||
|
|
b27bb1d030 | ||
|
|
c6330d494f | ||
|
|
19a25f8715 | ||
|
|
d5e7d5c253 | ||
|
|
fb68c4c6db | ||
|
|
aefcbfb1b7 | ||
|
|
9d253e9d84 | ||
|
|
a014083302 | ||
|
|
00a9b35e92 | ||
|
|
64d9fb1fc1 | ||
|
|
303163cb64 | ||
|
|
85f4b535e7 | ||
|
|
f139f13e3b | ||
|
|
d7c0cf975a | ||
|
|
1c131fd9e2 | ||
|
|
75d3269282 |
+1
-1
@@ -8,6 +8,6 @@
|
||||
"**/coverage/**",
|
||||
"nyc-config.cjs",
|
||||
".lintstagedrc.mjs",
|
||||
"cypress.config.js"
|
||||
"playwright/**/*.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,32 +10,52 @@ inputs:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
|
||||
outputs:
|
||||
image:
|
||||
description: 'Image name provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.image }}
|
||||
tags:
|
||||
description: 'Tag names provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.tags }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build backend image
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/oodikone-backend
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
image: oodikone-backend
|
||||
tags: ${{ inputs.environment }} ${{ github.sha }}
|
||||
context: services
|
||||
containerfiles: |
|
||||
services/backend/Dockerfile
|
||||
file: services/backend/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
@@ -14,32 +14,50 @@ inputs:
|
||||
required: false
|
||||
description: 'Sentry url to send error logs to (only production, enforced)'
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
image:
|
||||
description: 'Image name provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.image }}
|
||||
tags:
|
||||
description: 'Tag names provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.tags }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/oodikone-frontend
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
shell: bash
|
||||
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build frontend image
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
image: oodikone-frontend
|
||||
tags: ${{ inputs.environment }} ${{ github.sha }}
|
||||
context: services
|
||||
containerfiles: |
|
||||
services/frontend/Dockerfile
|
||||
file: services/frontend/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
@@ -48,3 +66,7 @@ runs:
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
|
||||
SENTRY_DSN=${{ case(inputs.environment == 'production', inputs.sentry_dns, '') }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
@@ -10,31 +10,51 @@ inputs:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
|
||||
outputs:
|
||||
image:
|
||||
description: 'Image name provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.image }}
|
||||
tags:
|
||||
description: 'Tag names provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.tags }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build scheduler image
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/updater-scheduler
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
image: updater-scheduler
|
||||
tags: ${{ inputs.environment }} ${{ github.sha }}
|
||||
context: updater/sis-updater-scheduler
|
||||
containerfiles: |
|
||||
updater/sis-updater-scheduler/Dockerfile
|
||||
file: updater/sis-updater-scheduler/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
@@ -10,31 +10,51 @@ inputs:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
|
||||
outputs:
|
||||
image:
|
||||
description: 'Image name provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.image }}
|
||||
tags:
|
||||
description: 'Tag names provided by buildah action'
|
||||
value: ${{ steps.build-image-action.outputs.tags }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build worker image
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/updater-worker
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: redhat-actions/buildah-build@v2
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
image: updater-worker
|
||||
tags: ${{ inputs.environment }} ${{ github.sha }}
|
||||
context: updater/sis-updater-worker
|
||||
containerfiles: |
|
||||
updater/sis-updater-worker/Dockerfile
|
||||
file: updater/sis-updater-worker/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
name: 'Lint code'
|
||||
description: 'Runs ESLint and Stylelint for frontend and backend'
|
||||
description: 'Runs Oxlint and Stylelint for frontend and backend'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: 'Install dependencies'
|
||||
@@ -13,6 +13,6 @@ runs:
|
||||
- name: 'Run Stylelint for CSS files'
|
||||
shell: bash
|
||||
run: npm run stylelint "services/frontend/src/**/*.css"
|
||||
- name: 'Run ESLint for JS and TS files'
|
||||
- name: 'Run Oxlint for the whole repository'
|
||||
shell: bash
|
||||
run: npm run eslint -- services
|
||||
run: npm run lint
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
name: Teardown docker compose (docker-compose.ci.yml)
|
||||
description: 'Teardown all containers started by docker (docker-compose.ci.yml)'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 'Teardown docker compose'
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml --profile="*" down --remove-orphans
|
||||
@@ -24,6 +24,21 @@ runs:
|
||||
username: ${{ inputs.username }}
|
||||
password: ${{ inputs.password }}
|
||||
|
||||
- name: Restore cached docker images
|
||||
id: cache-images-restore
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: |
|
||||
/tmp/docker-images.tar.zst
|
||||
key: docker-images-${{ github.sha }}
|
||||
|
||||
- name: Load Docker images (or skip if missing)
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f /tmp/docker-images.tar.zst ]; then
|
||||
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
fi
|
||||
|
||||
- name: Start oodikone (without frontend and backend)
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
|
||||
@@ -35,3 +50,7 @@ runs:
|
||||
- name: Run api-tests
|
||||
shell: bash
|
||||
run: npm run test:api --prefix ./services/backend
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
+11
-18
@@ -1,5 +1,5 @@
|
||||
name: 'Run tests'
|
||||
description: 'Runs Cypress tests for individual spec'
|
||||
name: Run e2e tests with playwright
|
||||
description: Runs playwright tests for individual spec
|
||||
|
||||
inputs:
|
||||
username:
|
||||
@@ -10,7 +10,7 @@ inputs:
|
||||
description: 'Toska registry password'
|
||||
spec:
|
||||
required: true
|
||||
description: 'Path to Cypress file'
|
||||
description: 'Path to test file'
|
||||
test_name:
|
||||
required: true
|
||||
description: 'Name of the test'
|
||||
@@ -29,23 +29,16 @@ runs:
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
|
||||
|
||||
- name: 'Run cypress'
|
||||
uses: cypress-io/github-action@v7
|
||||
with:
|
||||
browser: firefox
|
||||
spec: ${{ inputs.spec }}
|
||||
env:
|
||||
CYPRESS_baseUrl: http://localhost:1337
|
||||
|
||||
- name: 'Upload video if tests fail'
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: 'Cypress videos - ${{ inputs.test_name }}'
|
||||
path: ./cypress/videos/*
|
||||
retention-days: 7
|
||||
- name: Run playwright tests (${{ inputs.test_name }}})
|
||||
shell: bash
|
||||
run: |
|
||||
CI='true' npm run playwright ${{ inputs.spec }}
|
||||
|
||||
- name: 'Print logs on failure'
|
||||
shell: bash
|
||||
if: failure()
|
||||
run: npm run logs:ci && sleep 15
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
@@ -24,6 +24,21 @@ runs:
|
||||
username: ${{ inputs.username }}
|
||||
password: ${{ inputs.password }}
|
||||
|
||||
- name: Restore cached docker images
|
||||
id: cache-images-restore
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: |
|
||||
/tmp/docker-images.tar.zst
|
||||
key: docker-images-${{ github.sha }}
|
||||
|
||||
- name: Load Docker images (or skip if missing)
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f /tmp/docker-images.tar.zst ]; then
|
||||
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
fi
|
||||
|
||||
- name: Start oodikone (without frontend and backend)
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
|
||||
@@ -35,3 +50,7 @@ runs:
|
||||
- name: Run service-tests
|
||||
shell: bash
|
||||
run: npm run test:services --prefix ./services/backend
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
@@ -13,6 +13,10 @@ runs:
|
||||
shell: bash
|
||||
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/backend && npm ci --prefix ./services/frontend
|
||||
|
||||
- name: Run shared unit tests
|
||||
shell: bash
|
||||
run: npm run test:unit --prefix ./services/shared
|
||||
|
||||
- name: Run frontend unit tests
|
||||
shell: bash
|
||||
run: npm run test:unit --prefix ./services/frontend
|
||||
|
||||
@@ -7,10 +7,10 @@ on:
|
||||
jobs:
|
||||
release_backend:
|
||||
name: 'Build and release backend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -22,22 +22,15 @@ jobs:
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_frontend:
|
||||
name: 'Build and release frontend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -50,25 +43,18 @@ jobs:
|
||||
environment: 'production'
|
||||
release_version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
sentry_dns: ${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }} # Used only in production
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
sentry_release_frontend:
|
||||
name: 'Create Sentry release for frontend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -96,13 +82,13 @@ jobs:
|
||||
|
||||
sentry_release_backend:
|
||||
name: 'Create Sentry release for backend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -130,7 +116,7 @@ jobs:
|
||||
|
||||
report_success:
|
||||
name: 'Report deployment success to the "oodikone_notifications" channel'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
@@ -140,7 +126,7 @@ jobs:
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
@@ -151,7 +137,7 @@ jobs:
|
||||
|
||||
report_failure:
|
||||
name: 'Report deployment failure to the "oodikone_notifications" channel'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
@@ -161,7 +147,7 @@ jobs:
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
|
||||
@@ -13,89 +13,13 @@ on:
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'docker-compose.test.yml'
|
||||
- 'package*.json'
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
|
||||
jobs:
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
test:
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
test_unit:
|
||||
name: 'Run unit tests'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
needs:
|
||||
- lint_packages
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/test/unit
|
||||
|
||||
test_api:
|
||||
name: 'Test backend api'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
needs:
|
||||
- lint_packages
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/test/api
|
||||
|
||||
test_end_to_end:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
needs:
|
||||
- lint_packages
|
||||
- test_api
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- name: 'Test course statistics'
|
||||
spec: cypress/e2e/Course_statistics.js
|
||||
- name: 'Test custom population'
|
||||
spec: cypress/e2e/Custom_population.js
|
||||
- name: 'Test population statistics'
|
||||
spec: cypress/e2e/Population_statistics.js
|
||||
- name: 'Test filters'
|
||||
spec: cypress/e2e/Filters.js
|
||||
- name: 'Test students'
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test degree programme overview'
|
||||
spec: cypress/e2e/Degree_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
cypress/e2e/Users.js
|
||||
- name: 'Test faculty statistics'
|
||||
spec: cypress/e2e/Faculty_statistics.js
|
||||
- name: 'Test language center view'
|
||||
spec: cypress/e2e/Language_center.js
|
||||
- name: 'Test study guidance groups'
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
- name: 'Test numbers match'
|
||||
spec: cypress/e2e/Numbers_match.js
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/test/e2e
|
||||
with:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
uses: ./.github/workflows/test.yaml
|
||||
with:
|
||||
skip_db_update: true
|
||||
secrets:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
|
||||
@@ -13,8 +13,6 @@ on:
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'docker-compose.test.yml'
|
||||
- 'package*.json'
|
||||
- 'cypress/**'
|
||||
- 'cypress.config.json'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -25,11 +23,11 @@ jobs:
|
||||
|
||||
release_backend:
|
||||
name: 'Build and release backend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build backend'
|
||||
id: build-image
|
||||
@@ -37,23 +35,16 @@ jobs:
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_frontend:
|
||||
name: 'Build and release frontend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build frontend'
|
||||
id: build-image
|
||||
@@ -61,23 +52,16 @@ jobs:
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_updater_worker:
|
||||
name: 'Build and release updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build worker'
|
||||
id: build-image
|
||||
@@ -85,23 +69,16 @@ jobs:
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_updater_scheduler:
|
||||
name: 'Build and release updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build scheduler'
|
||||
id: build-image
|
||||
@@ -109,19 +86,12 @@ jobs:
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
report_success:
|
||||
name: 'Report deployment success to the "oodikone_notifications" channel'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: success()
|
||||
needs:
|
||||
- release_frontend
|
||||
@@ -130,7 +100,7 @@ jobs:
|
||||
- release_updater_scheduler
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
@@ -141,7 +111,7 @@ jobs:
|
||||
|
||||
report_failure:
|
||||
name: 'Report deployment failure to the "oodikone_notifications" channel'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: failure()
|
||||
needs:
|
||||
- release_frontend
|
||||
@@ -150,7 +120,7 @@ jobs:
|
||||
- release_updater_scheduler
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
|
||||
+206
-77
@@ -2,6 +2,12 @@ name: 'Lint and test'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip_db_update:
|
||||
description: 'When true, skip committing/pushing anon DB images (used for PR runs)'
|
||||
required: false
|
||||
type: string
|
||||
default: 'false'
|
||||
secrets:
|
||||
username:
|
||||
required: true
|
||||
@@ -14,46 +20,99 @@ jobs:
|
||||
# === Static tests / linting ===
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
# === Build database images ===
|
||||
build_backend:
|
||||
name: 'Build backend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build backend
|
||||
uses: ./.github/actions/build/backend
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: false
|
||||
|
||||
build_frontend:
|
||||
name: 'Build frontend'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build frontend
|
||||
uses: ./.github/actions/build/frontend
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: false
|
||||
|
||||
# === Cache docker images ===
|
||||
pull_docker_images:
|
||||
name: Pull all needed docker images and cache them (if dbs have not changed)
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
(vars.RUNS_ON == 'ubuntu-latest' || vars.RUNS_ON == '') &&
|
||||
!(
|
||||
needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
|
||||
needs.check_migration_files.outputs.kone_db_migrations == 'true' ||
|
||||
needs.check_migration_files.outputs.user_db_migrations == 'true' ||
|
||||
needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
)
|
||||
needs:
|
||||
- check_updater_changed
|
||||
- check_migration_files
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Login to toska docker registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: Set up cache for pulled docker images (saved in Post-step)
|
||||
id: cache-images
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: /tmp/docker-images.tar.zst
|
||||
key: docker-images-${{ github.sha }}
|
||||
|
||||
- name: Pull required images
|
||||
if: steps.cache-images.outputs.cache-hit != 'true'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone --profile updater pull --ignore-buildable --quiet
|
||||
|
||||
- name: Save Docker images for downstream jobs
|
||||
if: steps.cache-images.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker save \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db \
|
||||
"quay.io/toska/jami:production" \
|
||||
redis:7.2.6 \
|
||||
postgres:15.10 \
|
||||
nginx:latest \
|
||||
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
|
||||
|
||||
# === Update databases ===
|
||||
check_migration_files:
|
||||
name: Check if new migrations have been added
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v4
|
||||
@@ -71,18 +130,20 @@ jobs:
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
needs.check_migration_files.outputs.sis_db_migrations == 'true' &&
|
||||
inputs.skip_db_update != 'true'
|
||||
needs:
|
||||
- check_migration_files
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- lint_packages
|
||||
- test_unit
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
@@ -94,22 +155,29 @@ jobs:
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull sis-updater-worker
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
|
||||
# TODO: Disable this until fixed
|
||||
# ./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
build_and_push_anon_kone_db:
|
||||
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
needs.check_migration_files.outputs.kone_db_migrations == 'true' &&
|
||||
inputs.skip_db_update != 'true'
|
||||
needs:
|
||||
- check_migration_files
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- lint_packages
|
||||
- test_unit
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
@@ -122,22 +190,29 @@ jobs:
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
|
||||
# TODO: Disable this until fixed
|
||||
# ./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
build_and_push_anon_user_db:
|
||||
name: 'Build anon user-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.check_migration_files.outputs.user_db_migrations == 'true'
|
||||
needs.check_migration_files.outputs.user_db_migrations == 'true' &&
|
||||
inputs.skip_db_update != 'true'
|
||||
needs:
|
||||
- check_migration_files
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- lint_packages
|
||||
- test_unit
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
@@ -150,19 +225,24 @@ jobs:
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
|
||||
# TODO: Disable this until fixed
|
||||
# ./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
# === Update sis-db with updater ===
|
||||
check_updater_changed:
|
||||
name: 'Check if updater has been updated'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
outputs:
|
||||
has_updater_changed: ${{ steps.check_updater_changed.outputs.has_updater_changed }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: check_updater_changed
|
||||
with:
|
||||
@@ -175,22 +255,18 @@ jobs:
|
||||
|
||||
run_updater_updates:
|
||||
name: 'Run updater tests and updates'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
(needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
|
||||
needs.check_migration_files.outputs.kone_db_migrations == 'true' ||
|
||||
needs.check_migration_files.outputs.user_db_migrations == 'true' ||
|
||||
needs.check_migration_files.outputs.sis_db_migrations == 'true')
|
||||
needs:
|
||||
- check_migration_files
|
||||
- check_updater_changed
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
@@ -200,7 +276,7 @@ jobs:
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: Start worker containers
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-worker --quiet-pull --detach --wait
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-worker --scale sis-updater-worker=3 --quiet-pull --detach --wait
|
||||
|
||||
- name: Run hourly jobs
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
@@ -216,26 +292,34 @@ jobs:
|
||||
|
||||
# TODO: Push the updated sis-db to toska-hub when updater is deterministic with test-data
|
||||
# - name: Push newly build db to toska hub
|
||||
# if: success()
|
||||
# if: |
|
||||
# success() && inputs.skip_db_update != 'true'
|
||||
# run: ./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
|
||||
|
||||
- name: Dump logs on failure
|
||||
if: failure()
|
||||
run: npm run logs:ci
|
||||
run: docker compose --file docker-compose.ci.yml --profile updater logs
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
# === Backend api-tests ===
|
||||
test_api:
|
||||
name: 'Run backend api tests'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run api tests
|
||||
uses: ./.github/actions/test/api
|
||||
with:
|
||||
@@ -245,16 +329,19 @@ jobs:
|
||||
# === Backend service-tests ===
|
||||
test_services:
|
||||
name: 'Run backend service-tests'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run service-tests
|
||||
uses: ./.github/actions/test/services
|
||||
with:
|
||||
@@ -264,84 +351,109 @@ jobs:
|
||||
# === Unit testing ===
|
||||
test_unit:
|
||||
name: 'Run unit tests'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- run_updater_updates
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run unit tests (frontend and backend)
|
||||
uses: ./.github/actions/test/unit
|
||||
|
||||
# === Frontend component testing ===
|
||||
test_components:
|
||||
name: 'Test frontend components'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run frontend component tests
|
||||
uses: ./.github/actions/test/component
|
||||
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
test_end_to_end_playwright:
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: 'Test course statistics'
|
||||
spec: cypress/e2e/Course_statistics.js
|
||||
- name: 'Test custom population'
|
||||
spec: cypress/e2e/Custom_population.js
|
||||
- name: 'Test population statistics'
|
||||
spec: cypress/e2e/Population_statistics.js
|
||||
- name: 'Test filters'
|
||||
spec: cypress/e2e/Filters.js
|
||||
- name: 'Test students'
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
spec: e2e/Population_statistics.spec.ts
|
||||
- name: 'Test course statistics'
|
||||
spec: e2e/Course_statistics.spec.ts
|
||||
- name: 'Test degree programme overview'
|
||||
spec: cypress/e2e/Degree_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
cypress/e2e/Users.js
|
||||
- name: 'Test faculty statistics'
|
||||
spec: cypress/e2e/Faculty_statistics.js
|
||||
- name: 'Test language center view'
|
||||
spec: cypress/e2e/Language_center.js
|
||||
- name: 'Test study guidance groups'
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
- name: 'Test numbers match'
|
||||
spec: cypress/e2e/Numbers_match.js
|
||||
spec: e2e/Degree_programme_overview.spec.ts
|
||||
- name: 'Test Faculty statistics'
|
||||
spec: e2e/Faculty_statistics.spec.ts
|
||||
- name: 'Test teachers'
|
||||
spec: e2e/Teachers.spec.ts
|
||||
- name: 'Test users'
|
||||
spec: e2e/Users.spec.ts
|
||||
- name: 'Test access rights'
|
||||
spec: cypress/e2e/Routes.js
|
||||
spec: e2e/Routes.spec.ts
|
||||
- name: 'Test university'
|
||||
spec: e2e/University.spec.ts
|
||||
- name: 'Test language center view'
|
||||
spec: e2e/Language_center.spec.ts
|
||||
- name: 'Test study guidance groups'
|
||||
spec: e2e/Study_guidance_groups.spec.ts
|
||||
- name: 'Test custom population'
|
||||
spec: e2e/Custom_population.spec.ts
|
||||
- name: 'Test filters'
|
||||
spec: e2e/Filters.spec.ts
|
||||
- name: 'Test students'
|
||||
spec: e2e/Students.spec.ts
|
||||
- name: 'Test completed courses'
|
||||
spec: e2e/Completed_courses.spec.ts
|
||||
- name: 'Test numbers match'
|
||||
spec: e2e/Numbers_match.spec.ts
|
||||
name: ${{ matrix.name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install node-24 for cypress
|
||||
uses: actions/setup-node@v6
|
||||
- name: Install node-24 for playwright
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/test/e2e
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install firefox --with-deps
|
||||
|
||||
- name: Restore cached docker images
|
||||
id: cache-images-restore
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: |
|
||||
/tmp/docker-images.tar.zst
|
||||
key: docker-images-${{ github.sha }}
|
||||
|
||||
- name: Load Docker images (or skip if missing)
|
||||
run: |
|
||||
if [ -f /tmp/docker-images.tar.zst ]; then
|
||||
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/test/e2e-playwright
|
||||
with:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
@@ -351,14 +463,17 @@ jobs:
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- pull_docker_images
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
@@ -366,6 +481,20 @@ jobs:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: Restore cached docker images
|
||||
id: cache-images-restore
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: |
|
||||
/tmp/docker-images.tar.zst
|
||||
key: docker-images-${{ github.sha }}
|
||||
|
||||
- name: Load Docker images (or skip if missing)
|
||||
run: |
|
||||
if [ -f /tmp/docker-images.tar.zst ]; then
|
||||
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
fi
|
||||
|
||||
- name: 'Run services, build rest of the needed images'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull --wait
|
||||
|
||||
|
||||
@@ -10,24 +10,24 @@ on:
|
||||
jobs:
|
||||
lint_scheduler_and_worker:
|
||||
name: 'Lint scheduler and worker'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
run: npm run eslint -- updater
|
||||
run: npm run lint -- updater
|
||||
|
||||
build_worker:
|
||||
name: 'Build Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -48,10 +48,10 @@ jobs:
|
||||
|
||||
build_scheduler:
|
||||
name: 'Build Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -72,14 +72,14 @@ jobs:
|
||||
|
||||
test_updater:
|
||||
name: 'Test Updater'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- lint_scheduler_and_worker
|
||||
- build_worker
|
||||
- build_scheduler
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
@@ -127,10 +127,10 @@ jobs:
|
||||
|
||||
release_worker:
|
||||
name: 'Build and release Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs: test_updater
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build updater worker'
|
||||
id: build-image
|
||||
@@ -138,22 +138,15 @@ jobs:
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ github.sha }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_scheduler:
|
||||
name: 'Build and release Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
needs: test_updater
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build udpater scheduler'
|
||||
id: build-image
|
||||
@@ -161,12 +154,5 @@ jobs:
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ github.sha }}
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
with:
|
||||
image: ${{ steps.build-image.outputs.image }}
|
||||
tags: ${{ steps.build-image.outputs.tags }}
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
@@ -7,6 +7,8 @@ yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
test-results
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
npx lint-staged
|
||||
|
||||
if git grep -qE --cached "(it|describe)\.only" $(git diff --cached --name-only --diff-filter=ACMR | xargs) ./cypress; then
|
||||
if git grep -qE --cached "(it|describe)\.only" $(git diff --cached --name-only --diff-filter=ACMR | xargs); then
|
||||
echo "\e[0;31mDo not commit .only-tests."
|
||||
exit 1
|
||||
else
|
||||
|
||||
+23
-20
@@ -5,26 +5,29 @@ const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodi
|
||||
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
|
||||
|
||||
export default {
|
||||
// INFO: oxlint --type-check is equivalent to running tsc --noEmit
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
|
||||
'docker-compose*': files => {
|
||||
const composeFiles = file => {
|
||||
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
|
||||
return `--file ${file}`
|
||||
}
|
||||
if (file.includes('docker-compose.real.yml')) {
|
||||
return `--file ${cwd}/docker-compose.yml --file ${file}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
`oxlint --fix --type-aware --type-check --no-error-on-unmatched-pattern --quiet ${files.join(' ')}`,
|
||||
|
||||
return files.map(file => `docker-compose ${composeFiles(file)} config --quiet`)
|
||||
},
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files =>
|
||||
`oxfmt --no-error-on-unmatched-pattern ${files.join(' ')} `,
|
||||
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.14.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.11.0 ${relativeFilePaths(files)} -x`,
|
||||
'docker-compose*': files => {
|
||||
const composeFiles = file => {
|
||||
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
|
||||
return `--file ${file}`
|
||||
}
|
||||
if (file.includes('docker-compose.real.yml')) {
|
||||
return `--file ${cwd}/docker-compose.yml --file ${file}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return files.map(file => `docker compose ${composeFiles(file)} config --quiet`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"arrowParens": "avoid",
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf",
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": []
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
name: 'oodikone',
|
||||
},
|
||||
rules: {
|
||||
'no-new-date': {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Require the backend clock helper for the current date',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useNow: 'Use now() instead of new Date() so the current time can be controlled in tests.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
NewExpression(node) {
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'Date' && node.arguments.length === 0) {
|
||||
context.report({ node, messageId: 'useNow' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import", "typescript"],
|
||||
"jsPlugins": ["./.oxlint-backend-rules.cjs"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"options": {
|
||||
"typeAware": true,
|
||||
"typeCheck": true
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"es2021": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"settings": {
|
||||
"import-x/extensions": [".ts", ".tsx", ".cts", ".mts", ".js", ".jsx", ".cjs", ".mjs"],
|
||||
"import-x/external-module-folders": ["node_modules", "node_modules/@types"],
|
||||
"import-x/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx", ".cts", ".mts"]
|
||||
},
|
||||
"import-x/resolver": {
|
||||
"typescript": true
|
||||
},
|
||||
"import-x/resolver-next": [
|
||||
{
|
||||
"interfaceVersion": 3,
|
||||
"name": "eslint-import-resolver-typescript"
|
||||
},
|
||||
{
|
||||
"interfaceVersion": 3,
|
||||
"name": "eslint-plugin-import-x:node"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"eslint.config.mjs",
|
||||
".oxlint-backend-rules.cjs",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/instrumented/**",
|
||||
"**/coverage/**",
|
||||
"nyc-config.cjs",
|
||||
".lintstagedrc.mjs",
|
||||
"playwright/**/*.js",
|
||||
"playwright.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"constructor-super": "error",
|
||||
"for-direction": "error",
|
||||
"getter-return": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-misleading-character-class": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-undef": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "none",
|
||||
"ignoreRestSiblings": true,
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-empty-function": "error",
|
||||
"class-methods-use-this": "error",
|
||||
"no-alert": "error",
|
||||
"no-await-in-loop": "error",
|
||||
"no-console": "error",
|
||||
"no-param-reassign": "error",
|
||||
"no-promise-executor-return": "error",
|
||||
"no-return-assign": "error",
|
||||
"object-shorthand": ["error", "always"],
|
||||
"prefer-const": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "^@mui/[^/]+$",
|
||||
"message": "Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds."
|
||||
},
|
||||
{
|
||||
"regex": "^src/.*",
|
||||
"message": "Root imports will not resolve in production build (instead silently crash)."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"import/namespace": "error",
|
||||
"import/default": "error",
|
||||
"import/export": "error",
|
||||
"import/no-named-as-default": "warn",
|
||||
"import/no-named-as-default-member": "warn",
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-commonjs": "error",
|
||||
"import/no-default-export": "error",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-array-delete": "error",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-duplicate-type-constituents": "error",
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}
|
||||
],
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-redundant-type-constituents": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-enum-comparison": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-unsafe-unary-minus": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/prefer-promise-reject-errors": "error",
|
||||
"typescript/require-await": "error",
|
||||
"typescript/restrict-plus-operands": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/adjacent-overload-signatures": "error",
|
||||
"typescript/ban-tslint-comment": "error",
|
||||
"typescript/class-literal-property-style": "error",
|
||||
"typescript/consistent-generic-constructors": "error",
|
||||
"typescript/consistent-indexed-object-style": "error",
|
||||
"typescript/consistent-type-assertions": "error",
|
||||
"typescript/dot-notation": "error",
|
||||
"typescript/no-confusing-non-null-assertion": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/non-nullable-type-assertion-style": "error",
|
||||
"typescript/prefer-find": "error",
|
||||
"typescript/prefer-for-of": "error",
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-nullish-coalescing": "warn",
|
||||
"typescript/prefer-optional-chain": "error",
|
||||
"typescript/prefer-regexp-exec": "error",
|
||||
"typescript/prefer-string-starts-ends-with": "error",
|
||||
"typescript/consistent-return": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
||||
"rules": {
|
||||
"constructor-super": "off",
|
||||
"getter-return": "off",
|
||||
"no-class-assign": "off",
|
||||
"no-const-assign": "off",
|
||||
"no-dupe-class-members": "off",
|
||||
"no-dupe-keys": "off",
|
||||
"no-func-assign": "off",
|
||||
"no-import-assign": "off",
|
||||
"no-new-native-nonconstructor": "off",
|
||||
"no-obj-calls": "off",
|
||||
"no-redeclare": "off",
|
||||
"no-setter-return": "off",
|
||||
"no-this-before-super": "off",
|
||||
"no-undef": "off",
|
||||
"no-unreachable": "off",
|
||||
"no-unsafe-negation": "off",
|
||||
"no-var": "error",
|
||||
"no-with": "off",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/src/**/*.{js,ts,tsx}"],
|
||||
"excludeFiles": ["services/backend/src/util/clock.ts"],
|
||||
"rules": {
|
||||
"oodikone/no-new-date": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/src/routes/*.{ts,js}"],
|
||||
"rules": {
|
||||
"typescript/no-misused-promises": "off",
|
||||
"typescript/consistent-return": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["{updater,services}/**/*.{js,cjs}"],
|
||||
"rules": {
|
||||
"typescript/await-thenable": "off",
|
||||
"typescript/consistent-return": "off",
|
||||
"typescript/consistent-type-exports": "off",
|
||||
"typescript/dot-notation": "off",
|
||||
"typescript/no-array-delete": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-confusing-void-expression": "off",
|
||||
"typescript/no-deprecated": "off",
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
"typescript/no-floating-promises": "off",
|
||||
"typescript/no-for-in-array": "off",
|
||||
"typescript/no-implied-eval": "off",
|
||||
"typescript/no-meaningless-void-operator": "off",
|
||||
"typescript/no-misused-promises": "off",
|
||||
"typescript/no-misused-spread": "off",
|
||||
"typescript/no-mixed-enums": "off",
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "off",
|
||||
"typescript/no-unnecessary-condition": "off",
|
||||
"typescript/no-unnecessary-qualifier": "off",
|
||||
"typescript/no-unnecessary-template-expression": "off",
|
||||
"typescript/no-unnecessary-type-arguments": "off",
|
||||
"typescript/no-unnecessary-type-assertion": "off",
|
||||
"typescript/no-unnecessary-type-conversion": "off",
|
||||
"typescript/no-unnecessary-type-parameters": "off",
|
||||
"typescript/no-unsafe-enum-comparison": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"typescript/no-unsafe-unary-minus": "off",
|
||||
"typescript/non-nullable-type-assertion-style": "off",
|
||||
"typescript/only-throw-error": "off",
|
||||
"typescript/prefer-find": "off",
|
||||
"typescript/prefer-includes": "off",
|
||||
"typescript/prefer-nullish-coalescing": "off",
|
||||
"typescript/prefer-optional-chain": "off",
|
||||
"typescript/prefer-promise-reject-errors": "off",
|
||||
"typescript/prefer-readonly": "off",
|
||||
"typescript/prefer-readonly-parameter-types": "off",
|
||||
"typescript/prefer-reduce-type-parameter": "off",
|
||||
"typescript/prefer-regexp-exec": "off",
|
||||
"typescript/prefer-return-this-type": "off",
|
||||
"typescript/prefer-string-starts-ends-with": "off",
|
||||
"typescript/promise-function-async": "off",
|
||||
"typescript/related-getter-setter-pairs": "off",
|
||||
"typescript/require-array-sort-compare": "off",
|
||||
"typescript/require-await": "off",
|
||||
"typescript/restrict-plus-operands": "off",
|
||||
"typescript/return-await": "off",
|
||||
"typescript/strict-boolean-expressions": "off",
|
||||
"typescript/switch-exhaustiveness-check": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/use-unknown-in-catch-callback-variable": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}"],
|
||||
"rules": {
|
||||
"react/react-compiler": "warn",
|
||||
|
||||
"react/display-name": "error",
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-comment-textnodes": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-find-dom-node": "error",
|
||||
"react/no-is-mounted": "error",
|
||||
"react/no-render-return-value": "error",
|
||||
"react/no-string-refs": "error",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/no-unknown-property": "error",
|
||||
"react/no-unsafe": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/require-render-return": "error",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/exhaustive-deps": "warn",
|
||||
"react/no-array-index-key": "error",
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
|
||||
"react/no-this-in-sfc": "error",
|
||||
"react/jsx-boolean-value": ["error", "never"],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{
|
||||
"allow": "as-needed",
|
||||
"extensions": [".jsx", ".tsx"]
|
||||
}
|
||||
],
|
||||
"react/jsx-no-useless-fragment": "error"
|
||||
},
|
||||
"plugins": ["react"]
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/tests/**/*.ts"],
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"services/backend/**/*.js",
|
||||
"updater/sis-updater-worker/src/db/migrations/*.cjs",
|
||||
"updater/sis-updater-scheduler/**/*.js"
|
||||
],
|
||||
"rules": {
|
||||
"import/no-commonjs": "off",
|
||||
"typescript/no-require-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["updater/sis-updater-worker/src/db/migrations/*.cjs", "services/backend/src/database/**/*.js"],
|
||||
"rules": {
|
||||
"no-empty-function": "off",
|
||||
"typescript/require-await": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/**/*.{js,ts}", "updater/**/*.js"],
|
||||
"rules": {
|
||||
"no-await-in-loop": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
"import/no-default-export": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["e2e/**/*.ts"],
|
||||
"rules": {
|
||||
"no-await-in-loop": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"services/frontend/tests/**/*.{ts,tsx}",
|
||||
"services/frontend/playwright/**/*.{ts,tsx}",
|
||||
"services/frontend/playwright-ct.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-filename-extension": "off",
|
||||
"import/no-default-export": "off"
|
||||
},
|
||||
"plugins": ["react"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -56,6 +56,10 @@ What different CLI options do:
|
||||
- Runs `docker system prune -a && docker volume prune -a`
|
||||
- This will remove all your Docker data, also related to any other applications than Oodikone!
|
||||
- Sometimes necessary after strange errors due to caching
|
||||
6. Clear node_nodules
|
||||
- Deletes "nodemod" docker volumes and all local node_modules from each service
|
||||
- Useful when resolving permission/installation/library issues without the need to reset all data
|
||||
- Runs `npm ci` for every service locally
|
||||
|
||||
## ⌨️ Development
|
||||
|
||||
@@ -133,8 +137,9 @@ Some useful commands are defined in `package.json` and can be run with `npm run
|
||||
- `npm run testupdater`: runs tests for updater
|
||||
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
|
||||
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
|
||||
- `npm run install:docker`: installs `node_modules` for each package inside containers
|
||||
- `npm run install:both`: runs both `npm run install:local` and `npm run install:docker`
|
||||
- `npm run ci:local`: same as above, but sticks to the package versions pinned in package-lock
|
||||
- `npm run ci:docker`: installs `node_modules` for each package inside containers
|
||||
- `npm run ci:both`: runs both `npm run ci:local` and `npm run ci:docker`
|
||||
|
||||
Once you have ran setup for Oodikone , you can just execute the first one (`npm run oodikone`). After starting and waiting for a while for containers to compile, Oodikone can be accessed at [http://localhost:3000](http://localhost:3000/) and Adminer (database investigation tool) at [http://localhost:5050](http://localhost:5050/). Adminer requires you to login with username `postgres` and with any password you choose (for example `p`).
|
||||
|
||||
@@ -170,19 +175,18 @@ Pre-commit hooks will fix auto-fixable problems. To set up quick formatting: In
|
||||
|
||||
## 🔨 Testing & CI
|
||||
|
||||
We use cypress for end-to-end testing. No unit tests are currently used.
|
||||
We use Playwright for end-to-end testing.
|
||||
|
||||
Cypress
|
||||
Playwright
|
||||
|
||||
- Can be launched in interactive mode with `npm run cypress open`. `package.json` defines entrypoint `npm run cypress` so you can basically run cypress with any arguments you want
|
||||
- Are defined in cypress -folder and cypress.config.json
|
||||
- The entire cypress test stack takes about 15 to 20 mins to run. Since tests are ran in our Github actions CI pipe, you're encouraged to take advantage of this instead of running all tests locally.
|
||||
- Running tests locally sometimes causes problems after running the whole tests suite due to some login-related issue. Also for this reason, it is usually best practice to only run single views locally, and let the CI run the entire test suite.
|
||||
- There are some different user types and cypress commands defined for testing. Take a look at these when debugging tests.
|
||||
- E2E specs are in the `playwright/` folder.
|
||||
- Run all migrated specs with `npm run playwright`.
|
||||
- Run headed mode with `npm run playwright:headed`.
|
||||
- Open UI mode with `npm run playwright:ui`.
|
||||
|
||||
Continuous integration (CI) works with Github actions and is defined in workflow files in `.github/workflows` folder:
|
||||
|
||||
- Oodikone setup for cypress and other tests in CI is defined in `docker-compose.ci.yml`. Take a look at this too if debugging github action workflows.
|
||||
- Oodikone setup for e2e and other tests in CI is defined in `docker-compose.ci.yml`. Take a look at this too if debugging github action workflows.
|
||||
- Tests are run on every push
|
||||
- After a successful test run, Oodikone is deployed to staging
|
||||
- After creating a release, Oodikone is deployed to production
|
||||
@@ -191,9 +195,13 @@ Continuous integration (CI) works with Github actions and is defined in workflow
|
||||
|
||||
## ❓FAQ
|
||||
|
||||
### Modules are missing after updating package.json
|
||||
### How to install a new package
|
||||
|
||||
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Prefer `npm run ci:docker` or `npm run install:docker`, which also fix `node_modules` ownership for the non-root runtime user. If you run installs manually, use root inside the container and restore ownership afterwards (`chown -R 1001:0 node_modules`) to avoid permission issues.
|
||||
Install the package locally to the relevant service with `npm install` followed by the script `npm run ci:docker`. The application running inside container uses your host-mounted package-lock; therefore when making any changes to packages do them first locally. Avoid running `npm install` inside docker containers. Our docker images run as non-root, and cannot write on the host mounted package-lock files (without sudo'ing and changing back the permissions later). The CLI has an option to clear all node_modules should the need arise.
|
||||
|
||||
### Modules are missing after an update to package.json
|
||||
|
||||
Module might be missing for example when someone else installs a new library and you pull the changes to the package files, without installing the acual packages. To sync the node_modules with the package files run `npm run ci:both`.
|
||||
|
||||
### Study guidance groups don't work on my machine
|
||||
|
||||
@@ -207,39 +215,37 @@ If that does not help, try option 5: _Docker system prune_. Notice that this cle
|
||||
|
||||
### How to run tests locally faster
|
||||
|
||||
Vite simplifies development but can be **very slow** when running tests. If you're curious about the reasons for this, you can read more in [this GitHub issue](https://github.com/cypress-io/cypress/issues/22968).
|
||||
Vite simplifies development but can be **very slow** when running tests.
|
||||
|
||||
To speed up testing, you can use Vite's preview mode. This will make the tests run faster, but keep in mind that you must rebuild the frontend code every time you make changes to it. To use the preview mode, follow these steps:
|
||||
|
||||
1. Run `npm run build` in the `services/frontend` directory.
|
||||
2. Run `npm run preview` in the `services/frontend` directory and keep it running.
|
||||
3. Update the `baseUrl` in [cypress.config.js](./cypress.config.js) to `http://localhost:4173`.
|
||||
3. Set `PLAYWRIGHT_BASE_URL=http://localhost:4173` for Playwright.
|
||||
4. Start Oodikone with `npm run oodikone`.
|
||||
|
||||
After these steps, you can run your tests:
|
||||
|
||||
- Use `npm run cypress open` to open the Cypress UI and run the tests interactively.
|
||||
- Use `npm run playwright:ui` to open Playwright UI and run tests interactively.
|
||||
|
||||
Tests can also be run in headless mode, similar to how they are run in the CI/CD pipeline. To do this, use:
|
||||
|
||||
```bash
|
||||
npm run cypress:run -- --spec <file path>
|
||||
npm run playwright -- <file path>
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```bash
|
||||
npm run cypress:run -- --spec "cypress/e2e/Language_center.js"
|
||||
npm run playwright -- playwright/Language_center.js
|
||||
```
|
||||
|
||||
Running tests in headless mode may also help if your browser keeps crashing when running tests interactively. If you want to run all tests, use:
|
||||
|
||||
```bash
|
||||
npm run cypress:run
|
||||
npm run playwright
|
||||
```
|
||||
|
||||
Remember to revert the `baseUrl` in [cypress.config.js](./cypress.config.js) to its original value when you're done.
|
||||
|
||||
## How to read clients minds
|
||||
|
||||
Search for client queries in grafana using a variation of this query:
|
||||
|
||||
@@ -2,9 +2,11 @@ x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 15s # How long startup period is
|
||||
|
||||
services:
|
||||
kone-db:
|
||||
|
||||
@@ -53,6 +53,7 @@ set_custom_select_prompt() {
|
||||
"Reset single database"
|
||||
"Restore data from dumps"
|
||||
"Docker system prune"
|
||||
"Nuke local node_modules"
|
||||
"Quit"
|
||||
)
|
||||
}
|
||||
@@ -78,6 +79,8 @@ while true; do
|
||||
restore_data_from_dumps;;
|
||||
"Docker system prune")
|
||||
docker_prune;;
|
||||
"Nuke local node_modules")
|
||||
nuke_node_modules;;
|
||||
"Quit")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/* eslint-disable import-x/no-extraneous-dependencies */
|
||||
/* eslint-disable import-x/no-default-export */
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
projectId: 'c3jsph',
|
||||
defaultCommandTimeout: 30000,
|
||||
requestTimeout: 30000,
|
||||
videoCompression: false,
|
||||
viewportWidth: 1920,
|
||||
viewportHeight: 1080,
|
||||
e2e: {
|
||||
// We've imported your old cypress plugins here.
|
||||
// You may want to clean this up later by importing these.
|
||||
setupNodeEvents(on, config) {
|
||||
require('./cypress/plugins/index.js')(on, config)
|
||||
return config
|
||||
},
|
||||
baseUrl: 'http://localhost:3000',
|
||||
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
|
||||
},
|
||||
video: true,
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const openCompletedCoursesModal = () => {
|
||||
cy.contains('button', 'Search completed courses of students').click()
|
||||
}
|
||||
|
||||
const hasLanded = () => {
|
||||
cy.contains('Search completed courses')
|
||||
cy.contains(
|
||||
'Here you can search by a list of student and course numbers to see whether students have completed certain courses yet'
|
||||
)
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
}
|
||||
|
||||
const selectSavedCourselist = name => {
|
||||
cy.cs('history-search').click()
|
||||
cy.cs('history-search').type(name)
|
||||
cy.cs('history-search').type('{downarrow}')
|
||||
cy.cs('history-search').type('{enter}')
|
||||
}
|
||||
|
||||
const createCourseList = (courseCodes, courseListName) => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.cs('course-list-input').type(courseCodes.join('\n'))
|
||||
cy.cs('search-name').type(courseListName)
|
||||
cy.cs('save-courselist').click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
|
||||
|
||||
const deleteAllPreviousSearches = () => {
|
||||
cy.cs('history-search').click()
|
||||
|
||||
cy.get('.MuiAutocomplete-popper li').then($options => {
|
||||
if ($options.length > 0) {
|
||||
cy.wrap($options).each(($option, index, $list) => {
|
||||
cy.wrap($option).click()
|
||||
cy.cs('delete-courselist').click()
|
||||
if (index < $list.length - 1) {
|
||||
cy.cs('history-search').click()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('When search modal is opened', () => {
|
||||
after(() => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
cy.url().should('include', '/completedcoursessearch')
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.cs('open-completed-courses-modal-button').click()
|
||||
})
|
||||
|
||||
it('Modal opens correctly', () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.contains('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert name for this course list if you wish to save it')
|
||||
})
|
||||
|
||||
it('Modal gets the correct course codes and student numbers from the URL', () => {
|
||||
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.cs('student-no-input').contains('433237, 457144')
|
||||
cy.cs('course-list-input').contains('TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
describe('When a search is executed with invalid input', () => {
|
||||
it('Returns empty table and notification', () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.cs('student-no-input').type('1')
|
||||
cy.cs('course-list-input').type('1')
|
||||
cy.cs('completed-courses-search-button').click()
|
||||
cy.cs('rights-notification').should(
|
||||
'contain.text',
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
cy.cs('rights-notification').contains('1')
|
||||
cy.cs('completed-courses-table-div').contains('Student number')
|
||||
cy.cs('completed-courses-table-div').contains('1').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('When a search with correct data is executed', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.cs('student-no-input').type(studentSet1.join('\n'))
|
||||
cy.cs('course-list-input').type(coursesSet1.join('\n'))
|
||||
cy.cs('completed-courses-search-button').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Pushes the query to url', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.url().should(
|
||||
'include',
|
||||
`/completedcoursessearch?courseList=${coursesSet1.join('&courseList=')}&studentList=${studentSet1.join('&studentList=')}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('Finds correct students and courses', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
for (const studentNumber of studentSet1) {
|
||||
cy.cs('completed-courses-table-div').contains(studentNumber)
|
||||
}
|
||||
for (const courseCode of coursesSet1) {
|
||||
cy.cs('completed-courses-table-div').contains(courseCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Courselist saving-related functions work', () => {
|
||||
beforeEach(() => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-cy=history-search]').length > 0) {
|
||||
deleteAllPreviousSearches()
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Course list can be saved', () => {
|
||||
const courseList = generateCourseListName()
|
||||
const courses = ['CSM14204', 'TKT10004']
|
||||
createCourseList(courses, courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.cs('course-list-input').contains(courses.join(', '))
|
||||
})
|
||||
|
||||
it('Course list can be deleted', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.cs('delete-courselist').click()
|
||||
cy.contains('You have no previous searches.')
|
||||
})
|
||||
|
||||
it('Course list can be updated', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.cs('course-list-input').type(',TKT10001')
|
||||
cy.cs('save-courselist').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.cs('course-list-input').contains('CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const fillName = () => {
|
||||
const name = `TEST-${new Date().getTime()}`
|
||||
cy.contains('Insert a name for this custom population if you wish to save it')
|
||||
cy.cs('custom-population-name-input').type(name)
|
||||
return name
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
cy.get('button').contains('Save').click()
|
||||
}
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.cs('history-search').type(`${name}{enter}`)
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.cs('history-search').click()
|
||||
cy.get('.MuiAutocomplete-popper > .MuiPaper-root')
|
||||
.find('li[role=option]')
|
||||
.then($lis => {
|
||||
const labels = [...$lis].map(li => li.textContent?.trim() || '')
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
if (index !== 0) cy.cs('history-search').click()
|
||||
cy.contains('.MuiAutocomplete-popper li[role=option]', label).click()
|
||||
cy.contains('button', 'Delete').click()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const fillForm = (content, separator) => {
|
||||
cy.cs('student-number-input').type(content.join(separator))
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
cy.cs('search-button').click()
|
||||
}
|
||||
|
||||
const searchFor = (studentnumbers, separator) => {
|
||||
fillForm(studentnumbers, separator)
|
||||
search()
|
||||
}
|
||||
|
||||
const hasLanded = () => {
|
||||
cy.contains('Credit accumulation')
|
||||
cy.contains('Programme distribution')
|
||||
cy.contains('Courses of population')
|
||||
cy.contains('Students')
|
||||
}
|
||||
|
||||
const containsAmountOfStudents = (amount = 0) => {
|
||||
cy.contains(`Credit accumulation (for ${amount} students)`)
|
||||
}
|
||||
|
||||
const containsSpecificStudents = (studentnumbers = []) => {
|
||||
cy.contains(`Students (${studentnumbers.length})`)
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains(`Students (${studentnumbers.length})`).click()
|
||||
})
|
||||
|
||||
studentnumbers.forEach(s => cy.contains(s))
|
||||
}
|
||||
|
||||
const checkRightsNotification = studentNumbers => {
|
||||
cy.cs('rights-notification').within(() => {
|
||||
cy.contains(
|
||||
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
|
||||
)
|
||||
cy.get('ul').within(() => {
|
||||
studentNumbers.forEach(number => cy.get('li').contains(number))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('Custom population tests', () => {
|
||||
const nonExistentStudentNumbers = ['123', 'X', '-']
|
||||
beforeEach(() => {
|
||||
cy.init('/custompopulation')
|
||||
cy.url().should('include', '/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
})
|
||||
|
||||
after(() => {
|
||||
cy.visit('/custompopulation')
|
||||
cy.url().should('include', '/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
deleteAllSearches()
|
||||
})
|
||||
|
||||
describe('Custom population searching', () => {
|
||||
it('Finds a proper population', () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
|
||||
searchFor(students, '\n')
|
||||
hasLanded()
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
})
|
||||
})
|
||||
|
||||
it("Doesn't return non-existing students", () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
|
||||
searchFor([...students, ...nonExistentStudentNumbers], ' ')
|
||||
hasLanded()
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
checkRightsNotification(nonExistentStudentNumbers)
|
||||
})
|
||||
})
|
||||
|
||||
it("Doesn't find empty custom population", () => {
|
||||
searchFor(nonExistentStudentNumbers, ',')
|
||||
checkRightsNotification(nonExistentStudentNumbers)
|
||||
cy.contains('Credit accumulation').should('not.exist')
|
||||
cy.contains('Programme distribution').should('not.exist')
|
||||
cy.contains('Courses of population').should('not.exist')
|
||||
cy.get('button').contains('Back to search form')
|
||||
})
|
||||
|
||||
it("Doesn't return students user has no right to", () => {
|
||||
// These students exist in the database, but the user doesn't have the right to view them
|
||||
const studentsForEduBachStudents = ['014990067', '013069465', '014853890']
|
||||
// Two semicolons on purpose, the page should be able to handle it
|
||||
searchFor(studentsForEduBachStudents, ';;')
|
||||
checkRightsNotification(studentsForEduBachStudents)
|
||||
cy.contains('Credit accumulation').should('not.exist')
|
||||
cy.contains('Programme distribution').should('not.exist')
|
||||
cy.contains('Courses of population').should('not.exist')
|
||||
cy.get('button').contains('Back to search form')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom population search saving', () => {
|
||||
it('Saves a custom population search', { retries: 2 }, () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
|
||||
cy.contains(
|
||||
'Insert student numbers to use for the population. Each student number needs to be separated with a comma, a semicolon, a space, or a line break.'
|
||||
)
|
||||
const name = fillName()
|
||||
// A comma and a newline on purpose, the page should be able to handle it
|
||||
fillForm(students, ',\n')
|
||||
save()
|
||||
|
||||
// Round 1
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
|
||||
// Round 2
|
||||
cy.visit('/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
})
|
||||
})
|
||||
|
||||
it('Updates a custom population search', () => {
|
||||
cy.fixture('customPopulations').then(
|
||||
({ studentNumbersForCSStudentsSet1: students1, studentNumbersForCSStudentsSet2: students2 }) => {
|
||||
const name = fillName()
|
||||
fillForm(students1, ' ')
|
||||
save()
|
||||
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students1.length)
|
||||
containsSpecificStudents(students1)
|
||||
cy.contains('Back to search form').click()
|
||||
selectSavedPopulation(name)
|
||||
fillForm(['\n', ...students2], ', ')
|
||||
save()
|
||||
|
||||
cy.visit('/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students1.length + students2.length)
|
||||
containsSpecificStudents([...students1, ...students2])
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,920 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
const today = new Date()
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const tagName = `tag-${new Date().getTime()}`
|
||||
|
||||
const selectYear = year => {
|
||||
// ? Testing the MUI X DatePicker seems very difficult. It does not detect data-cy and the tests fail in CI/CD
|
||||
cy.contains('Associated start year (optional)').click({ force: true })
|
||||
cy.get('[placeholder=YYYY]').type(year)
|
||||
}
|
||||
|
||||
const deleteTag = tagName => {
|
||||
cy.cs(`delete-tag-${tagName}-button`).click()
|
||||
cy.contains('Delete tag')
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.cs('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
describe('Degree programme overview', () => {
|
||||
describe('Degree programme selector', () => {
|
||||
it('Degree programme search filter', () => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
|
||||
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('year-toggle').click() // NOTE: Tests are written for calendar years
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information tab loads', () => {
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 0, 0],
|
||||
[2022, 25, 26, 47, 1, 3],
|
||||
[2021, 29, 32, 48, 0, 2],
|
||||
[2020, 26, 27, 12, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 1],
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 5],
|
||||
[2022, 47, 13],
|
||||
[2021, 48, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0]),
|
||||
[2023, 8, 8, 24],
|
||||
[2022, 25, 26, 43],
|
||||
[2021, 29, 32, 47],
|
||||
[2020, 26, 27, 11],
|
||||
[2019, 28, 34, 1],
|
||||
[2018, 40, 45, 0],
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 5],
|
||||
[2022, 43, 12],
|
||||
[2021, 47, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.cs('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 0, 0],
|
||||
['2022 - 2023', 25, 26, 35, 0, 0],
|
||||
['2021 - 2022', 29, 32, 58, 1, 5],
|
||||
['2020 - 2021', 26, 27, 30, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 1],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
|
||||
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.cs('students-of-the-study-programme-line-graph-section')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.cs('graduation-mode-selector').within(() => {
|
||||
cy.cs('select-median').click()
|
||||
})
|
||||
cy.cs('unset-median-bar-chart').within(() => {
|
||||
cy.contains('47 graduated').should('be.visible')
|
||||
cy.contains('47 graduated').hover()
|
||||
})
|
||||
|
||||
cy.get('.grad-vals').contains('47 students graduated in year 2022').should('be.visible')
|
||||
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('24 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('11 graduated over year late')
|
||||
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.should('contain', 32)
|
||||
.should('contain', 21)
|
||||
.should('contain', 17)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times of master programmes', () => {
|
||||
it('are split into three graphs', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
cy.cs('year-toggle').click() // Tests are written for calendar year
|
||||
|
||||
cy.cs('unset-breakdown-bar-chart')
|
||||
|
||||
cy.cs('graduation-mode-selector').within(() => {
|
||||
cy.cs('select-average').click()
|
||||
})
|
||||
cy.cs('unset-graduation-times-section')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('2 graduated').should('be.visible')
|
||||
cy.contains('2 graduated').hover()
|
||||
})
|
||||
|
||||
cy.get('.grad-vals').contains('2 students graduated in year 2021').should('be.visible')
|
||||
cy.contains('average study time: 4.5 semesters')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
|
||||
cy.cs('unset-graduation-times-section')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('11 graduated').should('be.visible')
|
||||
cy.contains('11 graduated').hover()
|
||||
})
|
||||
|
||||
cy.get('.grad-vals').contains('11 students graduated in year 2023').should('be.visible')
|
||||
cy.contains('average study time: 11.18 semesters')
|
||||
cy.contains('4 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
).as('stQuery')
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('StudyTracksAndClassStatisticsTab').click()
|
||||
})
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Study track overview section', () => {
|
||||
cy.cs('study-track-overview-info-box-button').click()
|
||||
cy.cs('study-track-overview-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it('Progress of students section', () => {
|
||||
cy.cs('progress-of-students-info-box-button').click()
|
||||
cy.cs('progress-of-students-info-box-content').contains('Kuvaa koulutusohjelmassa')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Yksittäinen palkki')
|
||||
})
|
||||
})
|
||||
|
||||
it('All sections are visible', () => {
|
||||
cy.cs('study-track-selector-section')
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Students of the degree programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 0, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 29, 8, 0, 35, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 46, 40, 0, 0, 6, 40, 0, 26, 20, 0, 45, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 0, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 0, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it.skip('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
|
||||
const dataForStudyTracks = [
|
||||
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
|
||||
]
|
||||
|
||||
dataForStudyTracks.forEach((data, index) => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').each((cell, i) => {
|
||||
cy.wrap(cell).contains(data[i])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.down.icon').click()
|
||||
})
|
||||
|
||||
cy.get('tbody tr.regular-row').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population link button works for', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2023 - 2024, 8 students')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2017 - 2026, 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('show-study-tracks-button').first().click()
|
||||
cy.contains('td', 'Matematiikka (MAT-MAT)').within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class of 2022 - 2023, 26 students')
|
||||
cy.contains('Showing 3 out of 26 students')
|
||||
})
|
||||
})
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
|
||||
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
|
||||
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
|
||||
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
|
||||
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
it('Progress section', () => {
|
||||
cy.cs('programme-progress-bar-chart-section').scrollIntoView()
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section').contains('58.5%').hover()
|
||||
cy.contains('Graduated: 134')
|
||||
})
|
||||
|
||||
// CI contains different data than locally
|
||||
it.skip('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-section').scrollIntoView()
|
||||
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.cs('unset-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
})
|
||||
|
||||
cy.get('text').contains('16').hover()
|
||||
cy.contains('On time: 16')
|
||||
|
||||
cy.cs('unset-graduation-times-section').within(() => {
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('text').contains('22').click()
|
||||
})
|
||||
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
|
||||
cy.get('text').contains('13').hover()
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 13')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').hover()
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('16 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
cy.wait('@stQuery')
|
||||
cy.contains('All students of the programme')
|
||||
cy.cs('study-track-select').click()
|
||||
cy.contains('Ekonometria')
|
||||
cy.contains('Tietojenkäsittelyteoria')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka').click()
|
||||
})
|
||||
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 0, 2, 1, 0, 3, 0],
|
||||
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 3, 1, 0, 4, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 24, 21, 0, 0, 2, 22, 0, 11, 13, 0, 24, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 0, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 0, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
cy.contains('Showing 10 out of 30 students')
|
||||
})
|
||||
|
||||
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
|
||||
it.skip('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('Progress of students of the study track MAT-MAT by starting year')
|
||||
cy.contains('Progress data is currently only available for all students of the degree programme.')
|
||||
})
|
||||
|
||||
it.skip('Average graduation times are displayed correctly', () => {
|
||||
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').hover()
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').hover()
|
||||
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
|
||||
cy.contains('median study time: 34 months')
|
||||
cy.contains('9 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Programme courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('ProgrammeCoursesTab').click()
|
||||
|
||||
cy.cs('by-credit-type-section')
|
||||
cy.cs('by-credit-type-tab')
|
||||
cy.cs('by-semester-tab')
|
||||
})
|
||||
|
||||
describe('By credit type tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('show-credits-students-toggle').click()
|
||||
cy.cs('year-toggle').click()
|
||||
cy.get('thead').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').eq(3).click()
|
||||
})
|
||||
})
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(8)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT11004')
|
||||
cy.get('td').eq(2).contains('Differentiaalilaskenta')
|
||||
cy.get('td').eq(3).contains('1240')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2021').click()
|
||||
cy.cs('to-year-select').click()
|
||||
cy.cs('to-year-select-option-2022').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(9)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21009')
|
||||
cy.get('td').eq(2).contains('Kandidaatintutkielma')
|
||||
cy.get('td').eq(3).contains('414')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('year toggle works', () => {
|
||||
cy.cs('year-toggle').click() // Default view now Academic years
|
||||
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2017').click()
|
||||
cy.cs('to-year-select').click()
|
||||
cy.cs('to-year-select-option-2023').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(5)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT11001')
|
||||
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
|
||||
cy.get('td').eq(3).contains('1265')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Fix sorting / filters
|
||||
it.skip('different sorting options work', () => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('odgi-zef0')
|
||||
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT-yht')
|
||||
cy.get('td').eq(1).contains('Muut opinnot')
|
||||
})
|
||||
|
||||
// Course name
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20003')
|
||||
cy.get('td').eq(1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
})
|
||||
|
||||
// Total credits
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT110')
|
||||
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20001')
|
||||
cy.get('td').eq(1).contains('Kypsyysnäyte')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.cs('year-toggle').click()
|
||||
cy.wait(500) // So that the toggle has time to load
|
||||
cy.get('thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 6)
|
||||
const headers = ['Type', 'Code', 'Name', 'Total credits', 'Credits produced by', 'Transferred credits']
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Degree students',
|
||||
'Open university (hetu)',
|
||||
'Open university (no hetu)',
|
||||
'Exchange students',
|
||||
'Other universities',
|
||||
'Separate studies',
|
||||
'Other',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.cs('show-credits-students-toggle').click()
|
||||
|
||||
cy.get('thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total students',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed',
|
||||
'Not included in total or passed',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 10)
|
||||
const headers = [
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Degree students',
|
||||
'Open university students (with hetu)',
|
||||
'Open university students (without hetu)',
|
||||
'Exchange students',
|
||||
'Other university students',
|
||||
'Separate studies',
|
||||
'Other students',
|
||||
'Students with transferred credits',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(5)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT11001')
|
||||
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
|
||||
cy.get('td').eq(3).contains('272')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('By semester tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('by-semester-tab').click()
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it.skip('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('DegreeCoursesTab').click()
|
||||
})
|
||||
|
||||
it('Curriculum section', () => {
|
||||
cy.cs('curriculum-section')
|
||||
cy.contains('Select curriculum to edit:')
|
||||
cy.cs('curriculum-picker').contains('2023–2026')
|
||||
// TODO: Test changing the curriculum
|
||||
})
|
||||
|
||||
describe('Credit criteria section', () => {
|
||||
it('info box', () => {
|
||||
cy.cs('credit-criteria-section')
|
||||
cy.cs('credit-criteria-info-box-button').click()
|
||||
cy.cs('credit-criteria-info-box-content').contains('Here you can change')
|
||||
})
|
||||
|
||||
it.skip('changing the credit criteria works', () => {
|
||||
// TODO: Test using the inputs
|
||||
// TODO: Button status
|
||||
// TODO: Test "previously set to _" text changing when saving
|
||||
})
|
||||
})
|
||||
|
||||
it('Degree course table', () => {
|
||||
cy.cs('degree-course-table')
|
||||
// TODO: Test clicking the arrow buttons
|
||||
// TODO: Test toggling visibility for MODULES and COURSES
|
||||
// TODO: Test changing criterion labels
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tags tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('TagsTab').click()
|
||||
})
|
||||
|
||||
it('info box', () => {
|
||||
cy.cs('create-new-tag-info-box-button').click()
|
||||
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
|
||||
})
|
||||
|
||||
describe('Adding tags to populations and removing them works', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('This degree programme does not have any tags yet')
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
deleteTag(tagName)
|
||||
cy.contains('This degree programme does not have any tags yet')
|
||||
})
|
||||
|
||||
it('can create and delete tags without start year', () => {
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('No associated start year')
|
||||
})
|
||||
|
||||
it.skip('can create and delete tags with start year', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('Associated start year 2022')
|
||||
})
|
||||
|
||||
it.skip('population link works', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.go('back')
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
cy.cs('personal-tag-toggle').click()
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs(`${tagName}-visibility-icon`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding tags to students and removing them works', () => {
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)
|
||||
|
||||
it.skip('can add tags to students', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
|
||||
cy.cs('add-students-button').click()
|
||||
cy.cs('add-students-text-field').type(studentInput)
|
||||
cy.cs('add-students-confirm-button').click()
|
||||
cy.contains('Students added to tag')
|
||||
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
|
||||
})
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(tagName)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
|
||||
deleteTag(tagName)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(tagName).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'onlyiamrights')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
})
|
||||
|
||||
it.skip('year selector', () => {
|
||||
// TODO: Implement this test
|
||||
// Things to test:
|
||||
// - Clicking the button works
|
||||
// - Selecting a study track works
|
||||
// - The button is disabled if only one year is selected
|
||||
// - The slider is not visible if the user does not have correct programme rights
|
||||
})
|
||||
|
||||
it('can access programme and correct tabs are visible', () => {
|
||||
cy.contains('Basic information')
|
||||
cy.contains('Study tracks and class statistics')
|
||||
|
||||
cy.contains('Update statistics').should('not.exist')
|
||||
cy.contains('Degree courses').should('not.exist')
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.cs('BasicInformationTab').click()
|
||||
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.cs('StudyTracksAndClassStatisticsTab').click()
|
||||
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.cs('DegreeCoursesTab').should('not.exist')
|
||||
cy.cs('TagsTab').should('not.exist')
|
||||
cy.cs('UpdateStatisticsTab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,287 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const path = require('path')
|
||||
|
||||
const timestamp = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }) // YYYY-MM-DD, taking the local time zone into account
|
||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
||||
|
||||
describe('Faculty statistics', () => {
|
||||
describe('Faculty list', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
})
|
||||
|
||||
it('contains faculty names and faculty codes', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
cy.contains('Oikeustieteellinen tiedekunta')
|
||||
cy.contains('H20')
|
||||
cy.contains('Lääketieteellinen tiedekunta')
|
||||
cy.contains('H30')
|
||||
cy.contains('Humanistinen tiedekunta')
|
||||
cy.contains('H40')
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('Farmasian tiedekunta')
|
||||
cy.contains('H55')
|
||||
cy.contains('Bio- ja ympäristötieteellinen tiedekunta')
|
||||
cy.contains('H57')
|
||||
cy.contains('Kasvatustieteellinen tiedekunta')
|
||||
cy.contains('H60')
|
||||
cy.contains('Valtiotieteellinen tiedekunta')
|
||||
cy.contains('H70')
|
||||
cy.contains('Svenska social- och kommunalhögskolan')
|
||||
cy.contains('H74')
|
||||
cy.contains('Maatalous-metsätieteellinen tiedekunta')
|
||||
cy.contains('H80')
|
||||
cy.contains('Eläinlääketieteellinen tiedekunta')
|
||||
cy.contains('H90')
|
||||
})
|
||||
|
||||
it('contains a working link to faculty page', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('a', 'Teologinen tiedekunta').click()
|
||||
cy.location('pathname').should('eq', '/faculties/hy-org-1000000580')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information tab', () => {
|
||||
describe('Admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.cs('students-of-the-faculty-info-box-button').click()
|
||||
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.cs('graduated-of-the-faculty-info-box-button').click()
|
||||
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Thesis writers of the faculty', () => {
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Credits produced by the faculty', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
|
||||
})
|
||||
})
|
||||
|
||||
it('All graphs and tables are shown', () => {
|
||||
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
})
|
||||
|
||||
it('Toggle programmes works', () => {
|
||||
cy.cs('faculty-programmes-shown-info').should('not.exist')
|
||||
cy.cs('programme-toggle').click()
|
||||
cy.cs('faculty-programmes-shown-info').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle study rights works', () => {
|
||||
cy.cs('faculty-exclude-specials-info').should('not.exist')
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('faculty-exclude-specials-info').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.cs(`${section}Graph`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Students by starting year tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Students by starting year').click()
|
||||
})
|
||||
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Info box', () => {
|
||||
cy.cs('faculty-student-table-info-box-button').click()
|
||||
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Hoverable country list', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Expandable rows', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Population links', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Percentage toggle works', () => {
|
||||
cy.cs('faculty-student-stats-table').should('be.visible')
|
||||
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
|
||||
cy.cs('percentage-toggle').click()
|
||||
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Progress').click()
|
||||
})
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it.skip('Info boxes', () => {
|
||||
cy.cs('FacultyProgress-info-box-button').click()
|
||||
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('BachelorMastersProgress-info-box-button').click()
|
||||
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
|
||||
it.skip('Progress bar charts exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Progress tables exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Graduation times').click()
|
||||
})
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Info boxes', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
|
||||
})
|
||||
|
||||
it.skip('Study times toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Year toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Degree programme toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Graphs are visible', () => {
|
||||
cy.cs('bachelor-graduation-times-section').should('be.visible')
|
||||
// ! No test data for bachelor + master
|
||||
cy.cs('master-graduation-times-section').should('be.visible')
|
||||
cy.cs('doctor-graduation-times-section').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,468 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const setClockToMockedDate = () => {
|
||||
const MOCKED_DATE = new Date('2024-08-30')
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
}
|
||||
|
||||
const checkFilteredStudentCount = studentCount => {
|
||||
cy.contains(`Students (${studentCount})`)
|
||||
}
|
||||
|
||||
describe("Population statistics with a master's programme", { testIsolation: false }, () => {
|
||||
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 26
|
||||
const expectedBaMaStudents = 24
|
||||
const expectedMasterOnlyStudents = 2
|
||||
|
||||
before(() => cy.init(pathToMathMSc2020))
|
||||
|
||||
it('Study track filter works', () => {
|
||||
cy.cs('studyTrackFilter-header').click()
|
||||
cy.selectFromDropdown('studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)', true)
|
||||
checkFilteredStudentCount(15)
|
||||
|
||||
cy.cs('studyTrackFilter-clear').click()
|
||||
cy.cs('studyTrackFilter-header').click()
|
||||
})
|
||||
|
||||
it('Study right type filter works', () => {
|
||||
cy.cs('studyRightTypeFilter-header').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
|
||||
|
||||
// Bachelor + master
|
||||
cy.get('input[name="studyRightTypeFilter"][value="1"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(expectedBaMaStudents)
|
||||
// Master only
|
||||
cy.get('input[name="studyRightTypeFilter"][value="2"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="2"]').should('be.checked')
|
||||
checkFilteredStudentCount(expectedMasterOnlyStudents)
|
||||
// All
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('studyRightTypeFilter-header').click()
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('Population Statistics', { testIsolation: false }, () => {
|
||||
const pathToMathBSc2020 = '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 27
|
||||
const graduated = 16
|
||||
const transferred = 3
|
||||
|
||||
before(() => cy.init(pathToMathBSc2020))
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it.skip('Study right type filter is not visible', () => {
|
||||
cy.contains('Study right type').should('not.exist')
|
||||
})
|
||||
|
||||
// TODO: FilterRadio is not working correcly
|
||||
it.skip('Transfer filter works and is set to "not transferred" by default', () => {
|
||||
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(transferred)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents + transferred)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents - graduated)
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
})
|
||||
|
||||
it('Enrollment filter works', () => {
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
|
||||
checkFilteredStudentCount(25)
|
||||
|
||||
cy.cs('enrollmentStatusFilter-clear').click()
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Credit filter works', () => {
|
||||
cy.cs('creditsEarnedFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('creditsEarnedFilter', 50, 150, 12)
|
||||
|
||||
cy.cs('creditsEarnedFilter-clear').click()
|
||||
cy.cs('creditsEarnedFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 21)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(13)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(14)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(1)
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 7)
|
||||
checkFilteredStudentCount(21)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Admission type filter works', () => {
|
||||
cy.cs('admissionTypeFilter-header').click()
|
||||
cy.selectFromDropdown('admissionTypeFilter', 0)
|
||||
checkFilteredStudentCount(18)
|
||||
cy.selectFromDropdown('admissionTypeFilter', 1)
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs('admissionTypeFilter-clear').click()
|
||||
cy.cs('admissionTypeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
|
||||
checkFilteredStudentCount(4)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(7)
|
||||
|
||||
cy.cs('courseFilter-clear').click()
|
||||
cy.cs('courseFilter-header').click()
|
||||
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
cy.cs('ageFilter-header').click()
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 12)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-clear').click()
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('"Reset All Filters" button works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(13)
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 7)
|
||||
checkFilteredStudentCount(8)
|
||||
|
||||
cy.cs('reset-all-filters').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics with Bachelor + Master', { testIsolation: false }, () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 47
|
||||
|
||||
before(() => cy.init(pathToMathBSc2017))
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(42)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').should('be.checked')
|
||||
checkFilteredStudentCount(18)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').should('be.checked')
|
||||
checkFilteredStudentCount(29)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-clear').click()
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Study right status filter works', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('studyRightStatusFilter-header').click()
|
||||
|
||||
// Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(0)
|
||||
// Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
// Non-Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
// Non-Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('studyRightStatusFilter-clear').click()
|
||||
cy.cs('studyRightStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
describe.skip('Course Statistics', { testIsolation: false }, () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats'
|
||||
const defaultAmountOfStudents = 48
|
||||
|
||||
before(() => cy.init(pathToLimits2021))
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it('Grade filter works', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('gradeFilter-header').click()
|
||||
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteredStudentCount(23)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
checkFilteredStudentCount(29)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
|
||||
cy.cs('gradeFilter-clear').should('not.exist')
|
||||
|
||||
cy.cs('gradeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme filter works and defaults to "Attainment"', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('programmeFilter-header').click()
|
||||
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Attainment')
|
||||
cy.selectFromDropdown('programmeFilter', 1, true)
|
||||
checkFilteredStudentCount(33)
|
||||
|
||||
cy.cs('programmeFilter-clear').click()
|
||||
cy.cs('programmeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(39)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(1)
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 10)
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
cy.cs('gradeFilter-header').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteredStudentCount(17)
|
||||
|
||||
cy.cs('gradeFilter-clear').click()
|
||||
cy.cs('gradeFilter-header').click()
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Population Statistics', { testIsolation: false }, () => {
|
||||
let defaultAmountOfStudents
|
||||
|
||||
before(() => {
|
||||
cy.init('/custompopulation')
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
defaultAmountOfStudents = students.length
|
||||
cy.cs('student-number-input').click()
|
||||
cy.cs('student-number-input').type(students.join('{enter}'))
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
})
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
cy.setRangeSelect('ageFilter', 24, 28, 5)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme filter works and defaults to "Active Study Right"', () => {
|
||||
cy.cs('programmeFilter-header').click()
|
||||
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
|
||||
cy.selectFromDropdown('programmeFilter', 1, true)
|
||||
checkFilteredStudentCount(3)
|
||||
|
||||
cy.cs('programmeFilter-clear').click()
|
||||
cy.cs('programmeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(3)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(2)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs('courseFilter-clear').click()
|
||||
cy.cs('courseFilter-header').click()
|
||||
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const chooseSemester = (semester, fromOrTo) => {
|
||||
cy.cs(`semester-${fromOrTo}`).click()
|
||||
cy.cs(`select-opt-${semester}`).click()
|
||||
cy.cs(`select-opt-${semester}`).should('not.have.class', 'visible')
|
||||
}
|
||||
|
||||
const checkNumbers = (numbers, numberOfColumns, mode) => {
|
||||
const offset = 1 // skip first col with codes / names
|
||||
cy.cs(`ooditable-${mode}`).find('table').find('tbody').as('tbody')
|
||||
cy.get('@tbody').find('tr').first().as('totalRow')
|
||||
cy.get('@totalRow').find('td').should('have.length', numberOfColumns)
|
||||
cy.get('@totalRow').find('td').eq(0).should('contain', 'All courses total')
|
||||
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('@totalRow')
|
||||
.find('td')
|
||||
.eq(index + offset)
|
||||
.should('contain', number)
|
||||
})
|
||||
}
|
||||
|
||||
describe('When language center is opened', () => {
|
||||
describe('as an admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/languagecenterview', 'admin')
|
||||
cy.cs('completions-button').click()
|
||||
})
|
||||
|
||||
describe('Faculties tab', () => {
|
||||
beforeEach(() => {
|
||||
chooseSemester('Syksy 2017', 'from')
|
||||
chooseSemester('Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
it('Initial view is correct', () => {
|
||||
cy.contains('All courses total')
|
||||
cy.contains('Academic and Professional')
|
||||
cy.contains('AYKK-RUKIRJ')
|
||||
})
|
||||
|
||||
it('Faculties tab shows numbers', () => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', 15)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
const numbers = [2076, 36, 9, 34, 759, 15, 40, 6, 51, 1, 24, 1059, 42, 0]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
.contains(number)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.cs('difference-button').click()
|
||||
checkNumbers([66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 1], 15, 'faculties')
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([1184, 28, 6, 28, 499, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Semester tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('By semesters').click()
|
||||
chooseSemester('Syksy 2017', 'from')
|
||||
chooseSemester('Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
it('Semester tab shows numbers', () => {
|
||||
checkNumbers([2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a user with no rights', () => {
|
||||
it('"Access denied" is shown', () => {
|
||||
cy.init('/languagecenterview', 'norights')
|
||||
cy.contains('Access denied')
|
||||
cy.contains("You don't currently have permission to view this page.")
|
||||
cy.contains('Special populations').click()
|
||||
cy.contains('Language center view').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const selectCourseStatus = (courseCode, status) => {
|
||||
cy.cs(`courseFilter-${courseCode}-selector`).click()
|
||||
cy.get(`[role="listbox"][aria-labelledby=courseFilter-${courseCode}]`).within(() => {
|
||||
cy.get('li').contains(status).click()
|
||||
})
|
||||
}
|
||||
|
||||
describe('Numbers should match between', () => {
|
||||
describe('Programme courses, Course statistics and Course population (no substitutions)', () => {
|
||||
const test = [
|
||||
// Code, name, total students, passed, not completed, failed*, enrolled no grade* (not used by Programme courses)
|
||||
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', '272', '253', (1 + 18).toString(), '1', '18'],
|
||||
['Course', 'MAT11003', 'Raja-arvot', '270', '249', (1 + 20).toString(), '1', '20'],
|
||||
['Course', 'MAT11004', 'Differentiaalilaskenta', '262', '248', (1 + 13).toString(), '1', '13'],
|
||||
['Course', 'MAT21002', 'Sarjat', '256', '250', (0 + 6).toString(), '0', '6'],
|
||||
['Course', 'MAT11005', 'Integraalilaskenta', '251', '234', (0 + 17).toString(), '0', '17'],
|
||||
['Course', 'MAT11002', 'Lineaarialgebra ja matriisilaskenta I', '247', '242', (1 + 4).toString(), '1', '4'],
|
||||
['Course', 'MAT12003', 'Todennäköisyyslaskenta I', '234', '227', (3 + 4).toString(), '3', '4'],
|
||||
['Course', 'MAT21001', 'Lineaarialgebra ja matriisilaskenta II', '230', '216', (4 + 10).toString(), '4', '10'],
|
||||
['Course', 'MAT21003', 'Vektorianalyysi I', '228', '202', (3 + 23).toString(), '3', '23'],
|
||||
['Course', 'MAT20005', 'Akateemiset taidot', '201', '178', (0 + 23).toString(), '0', '23'],
|
||||
['Module', 'MAT110', 'Matematiikka, perusopinnot', '195', '195', (0 + 0).toString(), '0', '0'],
|
||||
['Course', 'MAT21014', 'Johdatus logiikkaan I', '188', '170', (0 + 18).toString(), '0', '18'],
|
||||
]
|
||||
|
||||
it('in Programme courses', () => {
|
||||
// Move to Math bachelor programme's programme courses
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('ProgrammeCoursesTab').click()
|
||||
|
||||
// Sort by Total students
|
||||
cy.get('thead').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').eq(3).click()
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
test.forEach((course, index) => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
// Check a course's fields
|
||||
course.forEach((field, fieldIndex) => {
|
||||
if (fieldIndex <= 5) {
|
||||
// Failed, Enrolled no grade values not used
|
||||
cy.get('td').eq(fieldIndex).contains(field)
|
||||
}
|
||||
})
|
||||
|
||||
// Course link button, (name is second entry)
|
||||
// cy.cs(`${course.at(1).toLocaleLowerCase()}-course-statistics-link-button`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('in Course statistics', () => {
|
||||
test.forEach(course => {
|
||||
// Move to Course statistics
|
||||
cy.init(`/coursestatistics?courseCodes=%5B%22${course.at(1)}%22%5D&combineSubstitutions=false`, 'basic')
|
||||
cy.url().should('include', `/coursestatistics?courseCodes=%5B%22${course.at(1)}%22%5D`)
|
||||
cy.contains('Course statistics')
|
||||
cy.contains(course.at(2)) // course name
|
||||
|
||||
cy.cs('FromYearSelector').click()
|
||||
cy.cs('FromYearSelectorOption2017-2018').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(1).contains(course.at(3))
|
||||
cy.get('td').eq(2).contains(course.at(4))
|
||||
cy.get('td').eq(3).contains(course.at(6)) // jump over combined Not completed
|
||||
cy.get('td').eq(4).contains(course.at(7))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('in Course population', () => {
|
||||
test.forEach(course => {
|
||||
// Academic years between 2017-2023
|
||||
cy.init(
|
||||
`/coursepopulation?from=68&to=74&coursecodes=%5B%22${course.at(1)}%22%5D&includeSubstitutions=false`,
|
||||
'basic'
|
||||
)
|
||||
cy.contains(course.at(2)) // Course name
|
||||
|
||||
// Select course from Course-filter
|
||||
cy.cs('courseFilter-filter-card').click()
|
||||
cy.cs('courseFilter-selector').click()
|
||||
cy.cs('courseFilter-popper').within(() => {
|
||||
cy.contains('li', `${course.at(1)} - ${course.at(2)}`).click() // "code - name"
|
||||
})
|
||||
|
||||
selectCourseStatus(course.at(1), 'All')
|
||||
cy.contains(`Students (${course.at(3)})`)
|
||||
|
||||
selectCourseStatus(course.at(1), 'Passed')
|
||||
cy.contains(`Students (${course.at(4)})`)
|
||||
|
||||
selectCourseStatus(course.at(1), 'Failed')
|
||||
cy.contains(`Students (${course.at(6)})`)
|
||||
|
||||
selectCourseStatus(course.at(1), 'Enrolled, No Grade')
|
||||
cy.contains(`Students (${course.at(7)})`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Course statistics and Course population', () => {
|
||||
it.skip('when selecting one year')
|
||||
it.skip('when selecting the all years')
|
||||
})
|
||||
})
|
||||
@@ -1,443 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
// Now "Class statistics" in UI
|
||||
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.cs('population-programme-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
|
||||
)
|
||||
cy.cs('population-programme-selector-parent').click()
|
||||
cy.cs('population-programme-selector-parent').within(() => cy.contains(programme).click())
|
||||
}
|
||||
|
||||
const selectStudyTrack = studyTrack => {
|
||||
cy.cs('population-studytrack-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select study track')
|
||||
)
|
||||
cy.cs('population-studytrack-selector-parent').click()
|
||||
cy.cs('population-studytrack-selector-parent').within(() => cy.contains(studyTrack).click())
|
||||
}
|
||||
|
||||
describe('Population statistics tests', () => {
|
||||
const getPath = programme => {
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
|
||||
}
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
|
||||
})
|
||||
|
||||
describe('Population search', () => {
|
||||
it('Info box works', () => {
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
cy.cs('PopulationSearch-info-box-button').hover()
|
||||
cy.cs('PopulationSearch-info-box-content').should('be.visible')
|
||||
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
})
|
||||
|
||||
// TODO: Delete MUI
|
||||
it.skip('Form is usable', { retries: { runMode: 2 } }, () => {
|
||||
cy.contains('Search for class')
|
||||
cy.contains('See class').should('be.disabled')
|
||||
|
||||
cy.cs('population-year-selector').as('yearSelect')
|
||||
cy.cs('population-year-decrement').as('yearDecrement')
|
||||
cy.cs('population-year-increment').as('yearIncrement')
|
||||
|
||||
cy.get('@yearSelect').click()
|
||||
cy.contains('2018 - 2019').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
|
||||
cy.get('@yearDecrement').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2017 - 2018'))
|
||||
cy.get('@yearIncrement').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
|
||||
|
||||
cy.cs('population-studytrack-selector').within(() => {
|
||||
cy.get('input').should('have.attr', 'placeholder', 'No study tracks available')
|
||||
})
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('See class').should('be.enabled')
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
cy.contains('See class').should('be.enabled')
|
||||
})
|
||||
|
||||
describe('Correct population is shown for programme', () => {
|
||||
// TODO: Delete MUI
|
||||
it.skip('without study tracks', () => {
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2017 - 2018, 47 students')
|
||||
})
|
||||
|
||||
// TODO: Delete MUI
|
||||
it.skip('with study tracks', () => {
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
|
||||
cy.contains('studytrack MAST-MSM')
|
||||
cy.contains('Class size 1 students')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Population statistics is usable on general level', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
cy.cs('filtered-students')
|
||||
})
|
||||
|
||||
// This test sometimes fails on headless mode. It seems that the click on the
|
||||
// 'Fetch class with new settings' button doesn't always trigger history.push()
|
||||
// so the page doesn't reload. This is why waiting also doesn't help.
|
||||
it('Advanced settings work', { retries: 2 }, () => {
|
||||
cy.visit(pathToMathMSc2020)
|
||||
cy.get('[data-cy=advanced-toggle]').click()
|
||||
// only spring
|
||||
cy.cs('toggle-fall').click()
|
||||
cy.contains('Fetch class').click()
|
||||
cy.contains('Credit accumulation (for 17 students)')
|
||||
cy.url().should('not.include', 'semesters=FALL')
|
||||
// only fall
|
||||
cy.get('[data-cy=advanced-toggle]').click()
|
||||
cy.cs('toggle-fall').click()
|
||||
cy.cs('toggle-spring').click()
|
||||
cy.contains('Fetch class').click()
|
||||
cy.contains('Credit accumulation (for 9 students)')
|
||||
cy.url().should('not.include', 'semesters=SPRING')
|
||||
// spring + fall
|
||||
cy.get('[data-cy=advanced-toggle]').click()
|
||||
cy.cs('toggle-spring').click()
|
||||
cy.contains('Fetch class').click()
|
||||
cy.contains('Credit accumulation (for 26 students)')
|
||||
cy.url().should('include', 'semesters=FALL&semesters=SPRING')
|
||||
})
|
||||
|
||||
describe('Credit statistics', () => {
|
||||
it("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits gained').click()
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector, numbersOfStudents) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `[data-cy='credits-gained-table-${selector}']`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
cy.cs('credits-gained-table-All students of the class').within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024')
|
||||
cy.get('th').eq(1).contains('(49 months)')
|
||||
cy.get('th').eq(2).contains('Number of students')
|
||||
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
|
||||
cy.get('th').eq(3).contains('Percentage of population')
|
||||
})
|
||||
|
||||
for (const category of [
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
]) {
|
||||
category.forEach(({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(1).contains(value)
|
||||
cy.get('td').eq(2).contains(students)
|
||||
cy.get('td').eq(3).contains(percentage)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
cy.get(
|
||||
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
|
||||
).then(tds => {
|
||||
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
|
||||
expect(sum).to.equal(totalStudents)
|
||||
})
|
||||
})
|
||||
|
||||
it("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.cs('credit-statistics-tab').click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h5', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains(text)
|
||||
cy.get('td').eq(1).contains(data[index])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Courses of class', () => {
|
||||
it('Is displayed and link to individual course stats page works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/courseyearlystats**').as('coursePage')
|
||||
cy.cs('toggle-group-module-MAT110').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001') // Johdatus yliopistomatematiikkaan
|
||||
cy.contains('AYMAT11001') // Avoin yo: Johdatus yliopistomatematiikkaan
|
||||
cy.contains('57033') // Johdatus yliopistomatematiikkaan
|
||||
cy.contains('A57033') // Avoin yo: Johdatus yliopistomatematiikkaan
|
||||
})
|
||||
|
||||
it('Curriculum selection works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('Courses data is changed when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
|
||||
cy.cs('curriculum-picker').scrollIntoView()
|
||||
cy.cs('curriculum-picker').should('be.visible')
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
cy.contains('Students (27)')
|
||||
cy.cs('toggle-group-module-DIGI-k').should('exist')
|
||||
cy.cs('toggle-group-module-DIGI-k').scrollIntoView()
|
||||
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
|
||||
cy.cs('toggle-group-module-DIGI-k').click({ force: true })
|
||||
cy.contains('DIGI-100').should('exist')
|
||||
|
||||
cy.cs('curriculum-picker').scrollIntoView()
|
||||
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click({ force: true })
|
||||
cy.cs('toggle-group-module-DIGI-k').should('exist')
|
||||
cy.contains('DIGI-100').should('not.exist')
|
||||
})
|
||||
|
||||
it('Courses data is changed when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-filter-card').within(() => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
})
|
||||
|
||||
cy.contains('Students (16)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Students', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('ooditable-general').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('Taloustieteen maisteriohjelma')
|
||||
cy.contains('Todistusvalinta')
|
||||
cy.contains('Saksa')
|
||||
cy.contains('Female')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
})
|
||||
cy.cs('ooditable-courses').within(() => {
|
||||
cy.contains('MAT12001')
|
||||
cy.contains('MAT21001')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Modules tab' Displays correct modules based on the selected programme", () => {
|
||||
cy.cs('student-table-tabs').contains('Modules').click()
|
||||
cy.cs('ooditable-modules').within(() => {
|
||||
cy.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('ooditable-modules').within(() => {
|
||||
cy.contains('MAT011').should('not.exist')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
})
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create new tag')
|
||||
cy.cs('create-button').should('be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('When using admin', () => {
|
||||
it('Student list checking works as intended', () => {
|
||||
cy.init(pathToMathBSc2020, 'admin')
|
||||
const existing = '433237'
|
||||
const nonExisting = '550004'
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
cy.contains(existing)
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.get('#checkstudentsresults').within(() => {
|
||||
cy.cs('found-title').click()
|
||||
cy.cs('found-data').contains(existing)
|
||||
cy.cs('not-found-title').click()
|
||||
cy.cs('not-found-data').contains(nonExisting)
|
||||
cy.cs('not-searched-title').click()
|
||||
cy.cs('not-searched-data').contains('457144')
|
||||
})
|
||||
cy.contains('button', 'Close').click()
|
||||
cy.contains('Student numbers in list and in Sisu').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('When using IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020, 'onlyiamrights')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
})
|
||||
|
||||
it('Population statistics is visible', () => {
|
||||
cy.cs('PopulationQueryCard').within(() => {
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
})
|
||||
})
|
||||
|
||||
it('Only correct panels are visible', () => {
|
||||
cy.cs('panelview-parent').within(() => {
|
||||
cy.cs('Credit accumulation (for 27 students)')
|
||||
cy.cs('Credit statistics')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of class')
|
||||
})
|
||||
})
|
||||
|
||||
it('Ages cannot be ungrouped', () => {
|
||||
cy.contains('Age distribution')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Age distribution').click()
|
||||
})
|
||||
cy.contains('ui.checked.toggle.checkbox', 'Group ages').should('not.exist')
|
||||
})
|
||||
|
||||
it('Age filter is not visible', () => {
|
||||
cy.get("[data-cy='filtered-students']")
|
||||
cy.get("[data-cy='Age-filter-card']").should('not.exist')
|
||||
})
|
||||
|
||||
it('Students tab is not available', () => {
|
||||
cy.contains('Students (27)').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { userHeaders } from '../support/commands.js'
|
||||
|
||||
const getBaseRoutes = [
|
||||
['/', {}],
|
||||
['/changelog', {}],
|
||||
['/completedcoursessearch', {}],
|
||||
['/university', {}],
|
||||
['/feedback', {}],
|
||||
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: ['KH50_001'] }],
|
||||
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/users', { roles: ['admin'] }],
|
||||
['/users/334', { roles: ['admin'] }],
|
||||
['/updater', { roles: ['admin'] }],
|
||||
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
|
||||
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
|
||||
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
|
||||
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/teachers', { roles: ['teachers'] }],
|
||||
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
|
||||
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
|
||||
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
|
||||
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
|
||||
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
|
||||
]
|
||||
|
||||
for (const user of userHeaders) {
|
||||
if (user.uid === 'admin') continue
|
||||
describe(`${user.displayname} has access only to views that were intended`, () => {
|
||||
let userInfo
|
||||
before(() => {
|
||||
cy.request({ method: 'GET', url: '/api/login', headers: user }).then(({ body }) => (userInfo = body?.user))
|
||||
})
|
||||
|
||||
beforeEach(() => cy.wrap(userInfo).should('not.be.undefined'))
|
||||
beforeEach(() => {
|
||||
cy.intercept('**/api/**', req => {
|
||||
req.on('response', res => {
|
||||
expect(res.statusCode, `${req.method} ${req.url}`).to.be.lessThan(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
for (const [route, requirements] of getBaseRoutes) {
|
||||
it(`Checking access ${route}`, () => {
|
||||
cy.init(route, user.uid)
|
||||
|
||||
const requiredUserId = !requirements.userId || requirements.userId === userInfo.userId
|
||||
|
||||
const requiredRoles = !requirements.roles || requirements.roles?.some(role => userInfo.roles?.includes(role))
|
||||
|
||||
const requiredRights =
|
||||
!requirements.requireProgrammeRights ||
|
||||
userInfo.roles?.includes('fullSisuAccess') ||
|
||||
requirements.programmeRights.every(role => userInfo.programmeRights?.includes(role))
|
||||
|
||||
// This is defined in frontend ProtectedRoute component.
|
||||
const specialNeedsRoutes = [
|
||||
'populations',
|
||||
'study-programme',
|
||||
'coursepopulation',
|
||||
'coursestatistics',
|
||||
'students',
|
||||
'custompopulation',
|
||||
]
|
||||
|
||||
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
|
||||
? requiredRoles || requiredRights
|
||||
: requiredRoles && requiredRights
|
||||
|
||||
const shouldHaveAccess = mankeli && requiredUserId
|
||||
|
||||
if (shouldHaveAccess) {
|
||||
cy.contains('Something broke').should('not.exist')
|
||||
cy.contains('Access denied').should('not.exist')
|
||||
} else {
|
||||
cy.contains('Access denied')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const student = {
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const typeToSearch = text => {
|
||||
cy.cs('student-search').type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
typeToSearch(studentNumber)
|
||||
cy.contains('td', studentNumber).click()
|
||||
}
|
||||
|
||||
describe('Students tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('')
|
||||
cy.contains('Students').click()
|
||||
cy.url().should('include', '/students')
|
||||
cy.contains('Show student names')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Students search form is usable', () => {
|
||||
typeToSearch(student.lastName)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name')
|
||||
cy.contains('First names')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.lastName)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
})
|
||||
|
||||
it('Search term must be at least 4 characters long', () => {
|
||||
typeToSearch(student.lastName.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastName.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Can search with student number too', () => {
|
||||
typeToSearch(student.studentNumber)
|
||||
cy.contains(student.studentNumber)
|
||||
})
|
||||
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastName)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.email)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
})
|
||||
|
||||
it("'Update student' button is not shown", () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.go('back')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to course', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Tilastollinen päättely I (MAT12004)')
|
||||
.parent()
|
||||
.siblings()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT12004') // Tilastollinen päättely I
|
||||
cy.contains('AYMAT12004') // Avoin yo: Tilastollinen päättely I
|
||||
cy.contains('57046') // Johdatus tilastolliseen päättelyyn
|
||||
})
|
||||
|
||||
it('Has correct Sisu link', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('sisu-link')
|
||||
.should('have.attr', 'href')
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
|
||||
})
|
||||
|
||||
it('Semester enrollments can be toggled', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
.each(($tr, index) => {
|
||||
cy.wrap($tr).within(() => {
|
||||
cy.get('td').eq(0).should('have.text', programmes[index])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("Searching with bad name doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it("Searching with bad student number doesn't yield results", () => {
|
||||
typeToSearch('01114')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => cy.get('a').click())
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class of 2020 - 2021, 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').should('have.value', '5')
|
||||
})
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
cy.get('input').should('have.value', '10')
|
||||
})
|
||||
cy.contains('button', 'Show semester mean').click()
|
||||
})
|
||||
|
||||
describe('Bachelor Honours section', () => {
|
||||
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
|
||||
cy.visit('/students/495976')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
|
||||
cy.contains('Study modules').click()
|
||||
cy.get('[data-cy=main-modules] tbody tr')
|
||||
.should('have.length', 3)
|
||||
.each(($tr, index) => {
|
||||
const info = [
|
||||
['07.05.2020', 'Matemaattisten tieteiden kandiohjelma (KH50_001)', 'Hyv.'],
|
||||
['08.05.2018', 'Matematiikka, perusopinnot (MAT110)', '4'],
|
||||
['25.02.2020', 'Matematiikka, aineopinnot (MAT210)', '4'],
|
||||
]
|
||||
cy.wrap($tr).within(() => {
|
||||
info[index].forEach((text, i) => {
|
||||
cy.get('td').eq(i).contains(text)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
|
||||
cy.visit('/students/540355')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
|
||||
})
|
||||
|
||||
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
|
||||
cy.visit('/students/547934')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
|
||||
})
|
||||
|
||||
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
|
||||
cy.visit('/students/478837')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentNumber}`)
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin to see all students
|
||||
describe('When using admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/students', 'admin')
|
||||
})
|
||||
|
||||
it('Does not crash if student has no study rights or courses', () => {
|
||||
typeStudentNumberAndClick('450730')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
|
||||
it("'Update student' button is shown", () => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
|
||||
cy.visit('/students/453146')
|
||||
cy.contains('h2', 'Bachelor Honours').should('not.exist')
|
||||
})
|
||||
|
||||
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
|
||||
cy.visit('/students/550789')
|
||||
cy.contains('Study rights')
|
||||
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
|
||||
cy.get('[data-cy=KH40_005-0]').within(() => {
|
||||
cy.get('td').eq(1).click()
|
||||
})
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
|
||||
'have.css',
|
||||
'background-color',
|
||||
'rgb(232, 244, 255)'
|
||||
)
|
||||
})
|
||||
|
||||
it("If there's a study plan corresponding to the degree programme, completed credits are displayed", () => {
|
||||
cy.visit('/students/550789')
|
||||
cy.contains('Study rights')
|
||||
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
|
||||
cy.get('[data-cy=KH40_005-0]').within(() => {
|
||||
cy.get('td')
|
||||
.eq(4)
|
||||
.contains(/^185 cr$/)
|
||||
})
|
||||
})
|
||||
|
||||
it("If there's a study plan corresponding to the degree programme and the student hasn't graduated, percentage of completion is also displayed", () => {
|
||||
cy.visit('/students/458723')
|
||||
cy.contains('Study rights')
|
||||
cy.get('[data-cy=KH20_001-0]').contains('Oikeusnotaarin koulutusohjelma')
|
||||
cy.get('[data-cy=KH20_001-0]').within(() => {
|
||||
cy.get('td').eq(4).contains('69% (125 cr)')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Study guidance group tests', () => {
|
||||
describe('Study guidance group overview page', () => {
|
||||
describe('without study guidance groups', () => {
|
||||
it('shows the correct notification', () => {
|
||||
cy.init('/studyguidancegroups', 'admin')
|
||||
cy.contains('Study guidance groups')
|
||||
cy.contains('You do not have access to any study guidance groups.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('without correct rights', () => {
|
||||
it('shows the correct notification', () => {
|
||||
cy.init('/studyguidancegroups')
|
||||
cy.contains('Access denied')
|
||||
cy.contains(
|
||||
"You don't currently have permission to view this page. If you believe this is a mistake, please contact oodikone@helsinki.fi."
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with study guidance groups', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups', 'onlystudyguidancegroups')
|
||||
cy.intercept('/api/studyguidancegroups', { fixture: 'studyGuidanceGroups.json' })
|
||||
})
|
||||
|
||||
it('shows the correct data', () => {
|
||||
cy.contains('Study guidance groups')
|
||||
|
||||
const studyGuidanceGroupsTableContent = [
|
||||
['MAT kandit 2020', 3, 'Matemaattisten tieteiden kandiohjelma', '2020 - 2021'],
|
||||
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add starting year'],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
|
||||
})
|
||||
|
||||
it('has working links to single study guidance groups', () => {
|
||||
cy.cs('study-guidance-group-link-sgg-cypress-1').click()
|
||||
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups/sgg-cypress-1`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Page for single study guidance group', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('/api/studyguidancegroups', { fixture: 'studyGuidanceGroups.json' })
|
||||
})
|
||||
|
||||
it('has a working back button', () => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
|
||||
|
||||
cy.get('button').contains('Back to groups').click()
|
||||
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
|
||||
})
|
||||
|
||||
describe('without associated degree programme and year', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-2', 'onlystudyguidancegroups')
|
||||
})
|
||||
|
||||
it('shows the correct panes', () => {
|
||||
cy.contains('Oma ohjausryhmä')
|
||||
cy.cs('Credit accumulation (for 2 students)')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of population')
|
||||
cy.contains('Students (2)')
|
||||
cy.cs('Credit statistics').should('not.exist')
|
||||
})
|
||||
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
cy.contains('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General'])
|
||||
})
|
||||
})
|
||||
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
'Credits since 1.1.1970',
|
||||
'Start year at uni',
|
||||
'Degree programmes',
|
||||
'Tags',
|
||||
]
|
||||
cy.contains('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with associated degree programme and year', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
|
||||
})
|
||||
|
||||
it('shows the correct labels', () => {
|
||||
cy.get('h4').contains('MAT kandit 2020')
|
||||
cy.get('.MuiChip-root:first').contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get('.MuiChip-root:last').contains('2020 - 2021')
|
||||
})
|
||||
|
||||
it('shows the correct panes', () => {
|
||||
cy.cs('Credit accumulation (for 3 students)')
|
||||
cy.cs('Credit statistics')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of population')
|
||||
cy.contains('Students (3)')
|
||||
})
|
||||
|
||||
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
|
||||
cy.cs('Credit accumulation (for 3 students)').click()
|
||||
cy.get('label').contains('Show credits starting from the associated academic year').click()
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="true"]')
|
||||
cy.get('.date-picker')
|
||||
.eq(0)
|
||||
.within(() => cy.get('input').should('have.value', '01.08.2020'))
|
||||
cy.contains('Reset All Filters')
|
||||
cy.get('label').contains('Show credits starting from the associated academic year').click()
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
|
||||
})
|
||||
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
'Credits in HOPS',
|
||||
'Credits since 1.8.2020',
|
||||
'Start of study right',
|
||||
'Started in programme',
|
||||
'Semesters present',
|
||||
'Graduation date',
|
||||
'Start year at uni',
|
||||
'Other programmes',
|
||||
'Transferred from',
|
||||
'Admission type',
|
||||
'Gender',
|
||||
'Citizenships',
|
||||
'Curriculum period',
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
]
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('Teachers page tests', () => {
|
||||
beforeEach(() => {
|
||||
// login as admin = has teacher rights
|
||||
cy.init('/teachers', 'admin')
|
||||
cy.url().should('include', '/teachers')
|
||||
})
|
||||
|
||||
const teacher1 = 'Luokkanen Liisa Viljami'
|
||||
const teacher2 = 'Perälä Juhani Susanna'
|
||||
|
||||
const statisticsHeaders = ['Name', 'Credits', 'Credits transferred', 'Passed']
|
||||
|
||||
it('Check Statistics', () => {
|
||||
cy.cs('semester-start').click()
|
||||
cy.contains('Syksy 2020').click()
|
||||
cy.cs('course-providers').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('body').click(0, 0) // Click outside of the select
|
||||
cy.cs('search-statistics').click()
|
||||
|
||||
cy.contains('Teacher')
|
||||
cy.get('table thead tr th').should('have.length', 4)
|
||||
statisticsHeaders.forEach((header, index) => {
|
||||
cy.get('table thead tr th').eq(index).should('contain', header)
|
||||
})
|
||||
cy.contains('td', teacher1).siblings().eq(0).contains('235')
|
||||
cy.contains('td', teacher1).siblings().eq(2).contains('97.40%')
|
||||
cy.contains('td', teacher2).siblings().eq(0).contains('395')
|
||||
cy.contains('td', teacher2).siblings().eq(2).contains('98.78%')
|
||||
})
|
||||
|
||||
it('Teacher search works', () => {
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher1.split(' ')[0])
|
||||
cy.get('table tbody tr').should('have.length', 4)
|
||||
cy.get('table tbody tr').eq(2).contains('td', teacher1)
|
||||
})
|
||||
|
||||
it('Can check teacher page', () => {
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher2)
|
||||
|
||||
// Prevent opening in new tab
|
||||
cy.contains('a', teacher2).invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/teachers/hy-hlo-49026530')
|
||||
cy.contains(teacher2)
|
||||
cy.contains('Syksy 2023').click()
|
||||
cy.contains('Kevät 2019').click()
|
||||
cy.contains('tr', 'MAT12004').within(() => {
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
rowContent.forEach((content, index) => {
|
||||
cy.get('td').eq(index).contains(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Check leaderboad works', () => {
|
||||
cy.cs('Leaderboard').click()
|
||||
cy.cs('academic-year').click()
|
||||
cy.contains('2020-2021').click()
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
|
||||
|
||||
const checkProgressBarCharts = () => {
|
||||
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
|
||||
}
|
||||
|
||||
const checkProgressTables = () => {
|
||||
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
|
||||
}
|
||||
|
||||
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
|
||||
|
||||
const checkGraduationCharts = mode => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.cs(`${level}-${mode}-bar-chart`).within(() => {
|
||||
// Check that bar has loaded
|
||||
cy.contains('Loading content').should('not.exist')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('University view', { defaultCommandTimeout: 60 * 1000 }, () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/university')
|
||||
cy.contains('University')
|
||||
})
|
||||
|
||||
describe('Faculty progress tab', () => {
|
||||
it('contains all the correct progress bar charts', () => {
|
||||
checkProgressBarCharts()
|
||||
})
|
||||
|
||||
it('contains all the correct progress tables', () => {
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'All study rights / Special study rights excluded' toggle works", () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
// Nothing in this test is explained. Non of these values are verified.
|
||||
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('bachelors-faculty-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').hover()
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('0 Credits: 0')
|
||||
cy.contains('1 ≤ Credits < 45: 13')
|
||||
cy.contains('45 ≤ Credits < 90: 10')
|
||||
cy.contains('90 ≤ Credits < 135: 16')
|
||||
cy.contains('135 ≤ Credits < 180: 3')
|
||||
cy.contains('180 ≤ Credits: 2')
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.cs('faculty-progress-info-box-button').click()
|
||||
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty graduations tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
describe('Different modes work', () => {
|
||||
it('Breakdown', () => {
|
||||
cy.cs('graduation-mode-selector').within(() => {
|
||||
cy.cs('select-breakdown').click()
|
||||
})
|
||||
checkGraduationCharts('breakdown')
|
||||
})
|
||||
|
||||
it('Median', () => {
|
||||
cy.cs('graduation-mode-selector').within(() => {
|
||||
cy.cs('select-median').click()
|
||||
})
|
||||
checkGraduationCharts('median')
|
||||
})
|
||||
|
||||
it('Average', () => {
|
||||
cy.cs('graduation-mode-selector').within(() => {
|
||||
cy.cs('select-average').click()
|
||||
})
|
||||
checkGraduationCharts('average')
|
||||
})
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').hover()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const visibleLinks = {
|
||||
norights: ['University', 'Faculties', 'Special populations', 'Feedback'],
|
||||
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Feedback'],
|
||||
}
|
||||
|
||||
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const userButtonWorks = (username, mocking = false) => {
|
||||
cy.cs('nav-bar-user-button').click()
|
||||
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
|
||||
cy.contains('Language')
|
||||
cy.contains('suomi')
|
||||
cy.contains('English')
|
||||
cy.contains('svenska')
|
||||
cy.contains(mocking ? 'Stop mocking' : 'Log out')
|
||||
}
|
||||
|
||||
describe('Users tests', () => {
|
||||
describe('Using as user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'norights')
|
||||
containsLinks(visibleLinks.norights)
|
||||
userButtonWorks('norights')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Using as coursestatistics user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'onlycoursestatistics')
|
||||
containsLinks(visibleLinks.onlycoursestatistics)
|
||||
userButtonWorks('onlycoursestatistics')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Using as basic user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('')
|
||||
containsLinks(visibleLinks.basic)
|
||||
userButtonWorks('basic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Using as admin', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/users', 'admin')
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
containsLinks(visibleLinks.admin)
|
||||
userButtonWorks('admin')
|
||||
})
|
||||
|
||||
describe('can mock as other users', { retries: 3 }, () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('user-page-button-basic').click()
|
||||
cy.cs('mock-button').click()
|
||||
})
|
||||
|
||||
it('user button shows mocked user', () => {
|
||||
userButtonWorks('basic', true)
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.visit('/populations')
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
|
||||
cy.contains('h6', 'Degree programme')
|
||||
cy.cs('population-programme-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
|
||||
)
|
||||
cy.cs('population-programme-selector-parent').click()
|
||||
cy.cs('population-programme-selector-parent').within(() => cy.contains('Matemaattisten tieteiden kandiohjelma'))
|
||||
cy.cs('population-programme-selector-parent').within(() =>
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"studentSet1": ["433237", "457144", "458090", "474270", "479440"],
|
||||
"coursesSet1": ["TKT10001", "TKT10002", "TKT10003", "TKT10004"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"studentNumbersForCSStudentsSet1": ["433237", "457144", "458090", "465136"],
|
||||
"studentNumbersForCSStudentsSet2": ["474270", "479440", "470391", "474789"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"courseCodesSet1": ["TKT10001", "TKT10002"],
|
||||
"courseCodesSet2": ["TKT10003", "TKT10004", "TKT10005"]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "sgg-cypress-1",
|
||||
"name": {
|
||||
"fi": "MAT kandit 2020"
|
||||
},
|
||||
"members": [
|
||||
{ "personStudentNumber": "433237" },
|
||||
{ "personStudentNumber": "457144" },
|
||||
{ "personStudentNumber": "458090" }
|
||||
],
|
||||
"tags": {
|
||||
"studyProgramme": "KH50_001",
|
||||
"year": "2020"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sgg-cypress-2",
|
||||
"name": {
|
||||
"fi": "Oma ohjausryhmä"
|
||||
},
|
||||
"members": [{ "personStudentNumber": "474270" }, { "personStudentNumber": "479440" }],
|
||||
"tags": {
|
||||
"studyProgramme": null,
|
||||
"year": null
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
on('before:browser:launch', (browser = {}, launchOptions) => {
|
||||
// By manually setting the browser width and height in headless
|
||||
// mode, screenshots and videos will have a better quality
|
||||
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
|
||||
const width = 2560
|
||||
const height = 1440
|
||||
|
||||
if (browser.name === 'chrome' && browser.isHeadless) {
|
||||
launchOptions.args.push(`--window-size=${width},${height}`)
|
||||
launchOptions.args.push('--force-device-scale-factor=1')
|
||||
}
|
||||
return launchOptions
|
||||
})
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
//
|
||||
// ! IMPORTANT: here we need to set keys to be all lowercase, since
|
||||
// ! we're replacing headers after they've left browser / frontend.
|
||||
// All of these users are available in anon user-db
|
||||
const adminUserHeaders = {
|
||||
uid: 'admin',
|
||||
displayname: 'Admin User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users;grp-toska',
|
||||
mail: 'grp-toska+mockadminuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-6666666',
|
||||
}
|
||||
|
||||
const basicUserHeaders = {
|
||||
uid: 'basic',
|
||||
displayname: 'Basic User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users',
|
||||
mail: 'grp-toska+mockbasicuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-1234567',
|
||||
}
|
||||
|
||||
const onlycoursestatisticsUserHeaders = {
|
||||
uid: 'onlycoursestatistics',
|
||||
displayname: 'Onlycoursestatistics User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-basic-users',
|
||||
mail: 'grp-toska+mockonlycoursestatisticsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-7654321',
|
||||
}
|
||||
|
||||
const norightsUserHeaders = {
|
||||
uid: 'norights',
|
||||
displayname: 'Norights User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users',
|
||||
mail: 'grp-toska+mocknorightuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-0000000',
|
||||
}
|
||||
|
||||
const onlyIamRightsUserHeaders = {
|
||||
uid: 'onlyiamrights',
|
||||
displayname: 'Only IAM rights user',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'hy-employees;hy-mltdk-mat-jory',
|
||||
mail: 'grp-toska+mockonlyiamrightsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-1111111',
|
||||
}
|
||||
|
||||
// Also has element detail of "KH50_005" for the study guidance groups to work
|
||||
const onlyStudyGuidanceGroupsUser = {
|
||||
uid: 'onlystudyguidancegroups',
|
||||
displayname: 'Study Guidance Groups User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users',
|
||||
mail: 'grp-toska+mockonlystudyguidancegroupsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-2222222',
|
||||
}
|
||||
|
||||
export const userHeaders = [
|
||||
adminUserHeaders,
|
||||
basicUserHeaders,
|
||||
onlycoursestatisticsUserHeaders,
|
||||
norightsUserHeaders,
|
||||
onlyIamRightsUserHeaders,
|
||||
onlyStudyGuidanceGroupsUser,
|
||||
]
|
||||
|
||||
/**
|
||||
* Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
|
||||
*/
|
||||
Cypress.Commands.add('init', { prevSubject: false }, (path, userId = 'basic') => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
|
||||
cy.intercept({ hostname: 'localhost', url: '**', method: '*' }, req => {
|
||||
req.headers = headersToUse
|
||||
})
|
||||
|
||||
// TODO: Add wait for visit url.
|
||||
// This is block by course population query.
|
||||
cy.visit({ method: 'GET', url: path, headers: headersToUse })
|
||||
})
|
||||
|
||||
/**
|
||||
* Shorthand for using "Cypress Selectors" (CS), i.e., `data-cy` attributes.
|
||||
*/
|
||||
Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
|
||||
const selector = `[data-cy="${name}"]`
|
||||
|
||||
if (subject === undefined) {
|
||||
return cy.get(selector)
|
||||
}
|
||||
return cy.wrap(subject).find(selector)
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item from FilterSelect dropdown.
|
||||
* If the index is a number, select index-th item.
|
||||
* Otherwise select the item containing index text.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index, isMultiSelect = false) => {
|
||||
cy.cs(`${label}-selector`).click()
|
||||
|
||||
cy.get(`[role="listbox"][aria-labelledby="${label}"]`).within(() => {
|
||||
if (typeof index === 'number') cy.get('li').eq(index).click()
|
||||
else cy.get('li').contains(index).click()
|
||||
})
|
||||
|
||||
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item from FilterSearchableSelect dropdown.
|
||||
* If the index is a number, select index-th item.
|
||||
* Otherwise select the item containing index text.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromAutocomplete', { prevSubject: false }, (label, index) => {
|
||||
cy.cs(`${label}-selector`).click()
|
||||
cy.get(`#${label}-popper.MuiAutocomplete-popper [role="listbox"]`).within(() => {
|
||||
if (typeof index === 'number') cy.get('li').eq(index).click()
|
||||
else cy.get('li').contains(index).click()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Set range selector values FilterRange.
|
||||
* If the reset flag is set, the function tries to reset the original values.
|
||||
*/
|
||||
Cypress.Commands.add('setRangeSelect', { prevSubject: false }, (filter, min, max, expected, reset) => {
|
||||
const minText = min.toString()
|
||||
const maxText = max.toString()
|
||||
const expectedText = expected.toString()
|
||||
|
||||
const parent = `${filter}-filter-card`
|
||||
|
||||
cy.cs(parent)
|
||||
.cs('FilterRangeStart')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMin => {
|
||||
cy.cs(parent)
|
||||
.cs('FilterRangeEnd')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMax => {
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').type(minText)
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').type(maxText)
|
||||
|
||||
// This should be linked to other checking functions.
|
||||
// Maybe one day.
|
||||
cy.contains(`Students (${expectedText})`)
|
||||
|
||||
if (reset) {
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').type(initialMin)
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input')?.type(initialMax)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
|
||||
correctStats.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.filter(':visible')
|
||||
.eq(trIndex)
|
||||
.within(() => {
|
||||
values.forEach((value, tdIndex) => {
|
||||
cy.get('td').eq(tdIndex).contains(value)
|
||||
})
|
||||
})
|
||||
})
|
||||
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Hover on the selected element.
|
||||
*/
|
||||
Cypress.Commands.add('hover', { prevSubject: 'element' }, (subject, options = {}) => {
|
||||
cy.wrap(subject).trigger('mouseover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...options,
|
||||
})
|
||||
|
||||
cy.wrap(subject).trigger('mousemove', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...options,
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
require('./commands')
|
||||
|
||||
Cypress.on('uncaught:exception', error => {
|
||||
/* returning false here prevents Cypress from failing the test */
|
||||
return true
|
||||
})
|
||||
+13
-10
@@ -4,9 +4,11 @@ x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 30s # How long startup period is
|
||||
|
||||
services:
|
||||
backend:
|
||||
@@ -71,7 +73,7 @@ services:
|
||||
ports:
|
||||
- 4321:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
user-db:
|
||||
@@ -85,7 +87,7 @@ services:
|
||||
ports:
|
||||
- 4320:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
redis:
|
||||
@@ -112,7 +114,7 @@ services:
|
||||
ports:
|
||||
- 12345:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-importer-db:
|
||||
@@ -124,7 +126,7 @@ services:
|
||||
ports:
|
||||
- 12346:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
jami:
|
||||
@@ -145,14 +147,14 @@ services:
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami-db
|
||||
image: postgres:15.10
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
ports:
|
||||
- 5433:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
# This is still needed to make backend calls correctly route from localhost:5000/api to localhost:8080
|
||||
@@ -203,7 +205,8 @@ services:
|
||||
|
||||
sis-updater-worker:
|
||||
<<: *defaults
|
||||
container_name: oodikone-updater-worker
|
||||
# Disabled so that container can be scaled
|
||||
# container_name: oodikone-updater-worker
|
||||
build:
|
||||
context: updater/sis-updater-worker
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
@@ -4,9 +4,11 @@ x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 30s # How long startup period is
|
||||
|
||||
services:
|
||||
sis-db:
|
||||
@@ -23,5 +25,5 @@ services:
|
||||
ports:
|
||||
- 12345:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
+11
-9
@@ -7,9 +7,11 @@ x-build_defaults: &build_defaults
|
||||
dockerfile: dev.Dockerfile
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 30s # How long startup period is
|
||||
|
||||
services:
|
||||
adminer:
|
||||
@@ -94,7 +96,7 @@ services:
|
||||
volumes:
|
||||
- kone-db-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
user-db:
|
||||
@@ -108,7 +110,7 @@ services:
|
||||
volumes:
|
||||
- user-db-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
redis:
|
||||
@@ -134,7 +136,7 @@ services:
|
||||
ports:
|
||||
- 12345:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-importer-db:
|
||||
@@ -148,7 +150,7 @@ services:
|
||||
ports:
|
||||
- 12346:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
jami:
|
||||
@@ -171,7 +173,7 @@ services:
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami-db
|
||||
image: postgres:15.10
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
@@ -180,7 +182,7 @@ services:
|
||||
volumes:
|
||||
- jami-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-updater-worker:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const studentSet1 = ['433237', '457144', '458090', '474270', '479440']
|
||||
const coursesSet1 = ['TKT10001', 'TKT10002', 'TKT10003', 'TKT10004']
|
||||
|
||||
const hasLanded = async (page: Page) => {
|
||||
await expect(page.getByRole('button', { name: 'Search completed courses of students' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Here you can search by a list of student and course numbers to see whether students have completed certain courses yet'
|
||||
)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
const openSearch = async (page: Page) => {
|
||||
await init(page, '/completedcoursessearch')
|
||||
await hasLanded(page)
|
||||
}
|
||||
|
||||
const openCompletedCoursesModal = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Search completed courses of students' }).click()
|
||||
}
|
||||
|
||||
const selectSavedCourselist = async (page: Page, name: string) => {
|
||||
const searchInput = page.getByTestId('history-search').getByRole('combobox')
|
||||
await searchInput.fill(name)
|
||||
await page.getByRole('option', { name }).click()
|
||||
}
|
||||
|
||||
const deleteAllPreviousSearches = async (page: Page) => {
|
||||
const historySearch = page.getByTestId('history-search').getByRole('combobox')
|
||||
|
||||
while (await historySearch.isVisible()) {
|
||||
await historySearch.click()
|
||||
const options = page.getByRole('option')
|
||||
if ((await options.count()) === 0) break
|
||||
await options.first().click()
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||||
}
|
||||
await expect(page.getByText('You have no previous searches.')).toBeVisible()
|
||||
}
|
||||
|
||||
const createCourseList = async (page: Page, courseCodes: string[], courseListName: string) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').fill(courseCodes.join('\n'))
|
||||
await page.getByTestId('search-name').getByRole('textbox').fill(courseListName)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${Date.now()}`
|
||||
|
||||
test.describe('When search modal is opened', () => {
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
if (await page.getByTestId('history-search').getByRole('combobox').isVisible()) {
|
||||
await deleteAllPreviousSearches(page)
|
||||
}
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('Modal opens correctly', async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await expect(page.getByRole('dialog')).toContainText('Search completed courses of students')
|
||||
await expect(
|
||||
page.getByText('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Insert name for this course list if you wish to save it')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Modal gets the correct course codes and student numbers from the URL', async ({ page }) => {
|
||||
await init(
|
||||
page,
|
||||
'/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144'
|
||||
)
|
||||
await hasLanded(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await expect(page.getByTestId('student-no-input').getByRole('textbox')).toHaveValue('433237, 457144')
|
||||
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue('TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
test('Returns empty table and notification for invalid input', async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await page.getByTestId('student-no-input').getByRole('textbox').fill('1')
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').fill('1')
|
||||
await page.getByRole('button', { name: 'Search' }).click()
|
||||
await expect(page.getByTestId('rights-notification')).toContainText(
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
await expect(page.getByTestId('rights-notification')).toContainText('1')
|
||||
const table = page.getByTestId('completed-courses-table-div')
|
||||
await expect(table).toContainText('Student number')
|
||||
await expect(table.getByText('1', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('When a search with correct data is executed', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await page.getByTestId('student-no-input').getByRole('textbox').fill(studentSet1.join('\n'))
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').fill(coursesSet1.join('\n'))
|
||||
await page.getByRole('button', { name: 'Search' }).click()
|
||||
})
|
||||
|
||||
test('Pushes the query to url', async ({ page }) => {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(
|
||||
`/completedcoursessearch\\?courseList=${coursesSet1.join('&courseList=')}&studentList=${studentSet1.join('&studentList=')}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('Finds correct students and courses', async ({ page }) => {
|
||||
const table = page.getByTestId('completed-courses-table-div')
|
||||
for (const studentNumber of studentSet1) await expect(table).toContainText(studentNumber)
|
||||
for (const courseCode of coursesSet1) await expect(table).toContainText(courseCode)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Courselist saving-related functions work', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
if (await page.getByTestId('history-search').getByRole('combobox').isVisible()) {
|
||||
await deleteAllPreviousSearches(page)
|
||||
} else {
|
||||
await expect(page.getByText('You have no previous searches.')).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Course list can be saved', async ({ page }) => {
|
||||
const courseList = generateCourseListName()
|
||||
const courses = ['CSM14204', 'TKT10004']
|
||||
await createCourseList(page, courses, courseList)
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue(courses.join(', '))
|
||||
})
|
||||
|
||||
test('Course list can be deleted', async ({ page }) => {
|
||||
const courseList = generateCourseListName()
|
||||
await createCourseList(page, ['CSM14204', 'TKT10004'], courseList)
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByText('You have no previous searches.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Course list can be updated', async ({ page }) => {
|
||||
const courseList = generateCourseListName()
|
||||
await createCourseList(page, ['CSM14204', 'TKT10004'], courseList)
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').pressSequentially(',TKT10001')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue('CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const students1 = ['433237', '457144', '458090', '465136']
|
||||
const students2 = ['474270', '479440', '470391', '474789']
|
||||
const nonExistentStudentNumbers = ['123', 'X', '-']
|
||||
|
||||
const fillForm = async (page: Page, studentNumbers: string[], separator: string) => {
|
||||
// NOTE: This helper uses pressSequentially instead of the normal fill so that it can append to a textfield
|
||||
await page.getByTestId('student-number-input').getByRole('textbox').pressSequentially(studentNumbers.join(separator))
|
||||
}
|
||||
|
||||
const search = async (page: Page) => {
|
||||
await page.getByTestId('search-button').click()
|
||||
}
|
||||
|
||||
const searchFor = async (page: Page, studentNumbers: string[], separator: string) => {
|
||||
await fillForm(page, studentNumbers, separator)
|
||||
await search(page)
|
||||
}
|
||||
|
||||
const hasLanded = async (page: Page) => {
|
||||
await expect(page.getByText('Credit accumulation')).toBeVisible()
|
||||
await expect(page.getByText('Programme distribution')).toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).toBeVisible()
|
||||
await expect(page.getByText('Students (4)')).toBeVisible()
|
||||
}
|
||||
|
||||
const containsAmountOfStudents = async (page: Page, amount = 0) => {
|
||||
await expect(page.getByText(`Credit accumulation (for ${amount} students)`)).toBeVisible()
|
||||
await expect(page.getByText(`Students (${amount})`)).toBeVisible()
|
||||
}
|
||||
|
||||
const containsSpecificStudents = async (page: Page, studentNumbers: string[]) => {
|
||||
const studentsTab = page.getByText(`Students (${studentNumbers.length})`)
|
||||
await studentsTab.click()
|
||||
await Promise.all(studentNumbers.map(async number => await expect(page.getByText(number)).toBeVisible()))
|
||||
}
|
||||
|
||||
const checkRightsNotification = async (page: Page, studentNumbers: string[]) => {
|
||||
const notification = page.getByTestId('rights-notification')
|
||||
await expect(notification).toContainText(
|
||||
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
|
||||
)
|
||||
await Promise.all(studentNumbers.map(number => expect(notification.locator('li').getByText(number)).toBeVisible()))
|
||||
}
|
||||
|
||||
const fillName = async (page: Page) => {
|
||||
const name = `TEST-${Date.now()}`
|
||||
await expect(page.getByText('Insert a name for this custom population if you wish to save it')).toBeVisible()
|
||||
await page.getByTestId('custom-population-name-input').getByRole('textbox').fill(name)
|
||||
return name
|
||||
}
|
||||
|
||||
const selectSavedPopulation = async (page: Page, name: string) => {
|
||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeEnabled()
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeEnabled()
|
||||
|
||||
const searchInput = page.getByTestId('history-search').getByRole('combobox')
|
||||
await searchInput.fill(name)
|
||||
await searchInput.press('Enter')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeEnabled()
|
||||
}
|
||||
|
||||
const save = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
}
|
||||
|
||||
const deleteAllSearches = async (page: Page) => {
|
||||
if (await page.getByTestId('history-search').isVisible()) {
|
||||
const searchInput = page.getByTestId('history-search')
|
||||
await searchInput.click()
|
||||
const options = page.getByRole('cell')
|
||||
|
||||
while (await options.count()) {
|
||||
await options.first().click()
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
await searchInput.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Custom population tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/custompopulation')
|
||||
await expect(page).toHaveURL(/\/custompopulation/)
|
||||
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await init(page, '/custompopulation')
|
||||
await deleteAllSearches(page)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test.describe('Custom population searching', () => {
|
||||
test('Finds a proper population', async ({ page }) => {
|
||||
await searchFor(page, students1, '\n')
|
||||
await hasLanded(page)
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
})
|
||||
|
||||
test("Doesn't return non-existing students", async ({ page }) => {
|
||||
await searchFor(page, [...students1, ...nonExistentStudentNumbers], ' ')
|
||||
await hasLanded(page)
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
await checkRightsNotification(page, nonExistentStudentNumbers)
|
||||
})
|
||||
|
||||
test("Doesn't find empty custom population", async ({ page }) => {
|
||||
await searchFor(page, nonExistentStudentNumbers, ',')
|
||||
await checkRightsNotification(page, nonExistentStudentNumbers)
|
||||
await expect(page.getByText('Credit accumulation')).not.toBeVisible()
|
||||
await expect(page.getByText('Programme distribution')).not.toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Doesn't return students user has no right to", async ({ page }) => {
|
||||
const studentsForEduBachStudents = ['014990067', '013069465', '014853890']
|
||||
await searchFor(page, studentsForEduBachStudents, ';;')
|
||||
await checkRightsNotification(page, studentsForEduBachStudents)
|
||||
await expect(page.getByText('Credit accumulation')).not.toBeVisible()
|
||||
await expect(page.getByText('Programme distribution')).not.toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom population search saving', () => {
|
||||
test('Saves a custom population search', async ({ page }) => {
|
||||
const name = await fillName(page)
|
||||
await fillForm(page, students1, ',\n')
|
||||
await save(page)
|
||||
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
|
||||
await page.goto('/custompopulation')
|
||||
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
})
|
||||
|
||||
test('Updates a custom population search', async ({ page }) => {
|
||||
const name = await fillName(page)
|
||||
await fillForm(page, students1, ' ')
|
||||
await save(page)
|
||||
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
await page.getByRole('button', { name: 'Back to search form' }).click()
|
||||
await selectSavedPopulation(page, name)
|
||||
await fillForm(page, ['\n', ...students2], ', ')
|
||||
await save(page)
|
||||
|
||||
await page.goto('/custompopulation')
|
||||
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length + students2.length)
|
||||
await containsSpecificStudents(page, [...students1, ...students2])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,868 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { checkTableStats, init, testMockTime } from './support/commands'
|
||||
|
||||
const getEmptyYears = (isAcademicYear = false) => {
|
||||
const today = new Date(testMockTime)
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const tagName = `tag-${new Date().getTime()}`
|
||||
const selectYear = async (page: Page, year: number) => {
|
||||
await page.getByLabel('Associated start year (optional)').fill(year.toString())
|
||||
}
|
||||
|
||||
const deleteTag = async (page: Page, tagName: string) => {
|
||||
await page.getByTestId(`delete-tag-${tagName}-button`).click()
|
||||
await expect(page.getByText('Delete tag', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Are you sure you want to delete tag')).toBeVisible()
|
||||
await page.getByTestId('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
test.describe('Degree programme overview', () => {
|
||||
test.describe('Degree programme selector', () => {
|
||||
test('Degree programme search filter', async ({ page }) => {
|
||||
await init(page, '/study-programme', 'admin')
|
||||
await expect(page.getByText('Tietojenkäsittelytieteen kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
|
||||
await page.getByTestId('study-programme-filter').click()
|
||||
await page.getByPlaceholder('Type here to filter degree programmes').fill('Tietojenkäsittelytieteen')
|
||||
|
||||
await expect(page.getByText('Tietojenkäsittelytieteen kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
test.describe('Basic information tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('year-toggle').click() // NOTE: Tests are written for calendar years
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
test('Basic information tab loads', async ({ page }) => {
|
||||
await expect(page.getByTestId('students-of-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('credits-produced-by-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('graduated-and-thesis-writers-of-the-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('programmes-before-or-after-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Basic information contains correct students', async ({ page }) => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 2, 0, 0],
|
||||
[2022, 25, 26, 47, 0, 1, 3],
|
||||
[2021, 29, 32, 48, 1, 0, 2],
|
||||
[2020, 26, 27, 12, 0, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 0, 1],
|
||||
[2017, 41, 47, 0, 0, 0, 0],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
test('Basic information contains correct credits', async ({ page }) => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 10],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
test('Basic information contains correct thesis writers and graduates', async ({ page }) => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 5],
|
||||
[2022, 47, 13],
|
||||
[2021, 48, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
test('Special study rights can be excluded and basic data changes accordingly', async ({ page }) => {
|
||||
await page.getByTestId('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Cancelled]
|
||||
...years.map(year => [year, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 24, 2],
|
||||
[2022, 25, 26, 43, 0],
|
||||
[2021, 29, 32, 47, 1],
|
||||
[2020, 26, 27, 11, 0],
|
||||
[2019, 28, 34, 1, 0],
|
||||
[2018, 40, 45, 0, 0],
|
||||
[2017, 41, 47, 0, 0],
|
||||
]
|
||||
await checkTableStats(page, studentTableContents, 'students-of-the-study-programme')
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 5],
|
||||
[2022, 43, 12],
|
||||
[2021, 47, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
await checkTableStats(page, graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
test('Year can be changed to academic year, and data changes accordingly', async ({ page }) => {
|
||||
await page.getByTestId('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 1, 0, 0],
|
||||
['2022 - 2023', 25, 26, 35, 1, 0, 0],
|
||||
['2021 - 2022', 29, 32, 58, 1, 1, 5],
|
||||
['2020 - 2021', 26, 27, 30, 0, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 0, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 0, 1],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0, 0],
|
||||
]
|
||||
await checkTableStats(page, studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 10],
|
||||
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
|
||||
]
|
||||
await checkTableStats(page, creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
// await page.getByTestId('year-toggle').click()
|
||||
})
|
||||
|
||||
test('Basic information graphs render', async ({ page }) => {
|
||||
const graph = page.getByTestId('students-of-the-study-programme-line-graph-section')
|
||||
await expect(graph.getByText('Started studying')).toBeVisible()
|
||||
await expect(graph.getByText('Accepted')).toBeVisible()
|
||||
await expect(graph.getByText('Graduated')).toBeVisible()
|
||||
await expect(graph.getByText('Cancelled')).toBeVisible()
|
||||
await expect(graph.getByText('Transferred away')).toBeVisible()
|
||||
await expect(graph.getByText('Transferred to')).toBeVisible()
|
||||
|
||||
const creditsBarChart = page.getByTestId('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
await expect(creditsBarChart.getByText('Degree students')).toBeVisible()
|
||||
await expect(creditsBarChart.getByText('Transferred')).toBeVisible()
|
||||
await expect(creditsBarChart.getByText('5796')).toBeVisible()
|
||||
await expect(creditsBarChart.getByText('428')).toBeVisible()
|
||||
|
||||
const thesisBarChart = page.getByTestId('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
await expect(thesisBarChart.getByText('Graduated students')).toBeVisible()
|
||||
await expect(thesisBarChart.getByText('Wrote thesis')).toBeVisible()
|
||||
await expect(thesisBarChart.getByText('47')).toBeVisible()
|
||||
await expect(thesisBarChart.getByText('76')).toBeVisible()
|
||||
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-median').click()
|
||||
await expect(page.getByTestId('unset-median-bar-chart').getByText('47 graduated')).toBeVisible()
|
||||
|
||||
await page.getByTestId('unset-median-bar-chart').getByText('47 graduated').hover()
|
||||
await expect(page.locator('.grad-vals').getByText('47 students graduated in year 2022')).toBeVisible()
|
||||
await expect(page.getByText('median study time: 6 semesters')).toBeVisible()
|
||||
await expect(page.getByText('24 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('12 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('11 graduated over year late')).toBeVisible()
|
||||
|
||||
const programmesBarChart = page.getByTestId('programmes-before-or-after-stacked-bar-chart-section')
|
||||
await expect(programmesBarChart.getByText('Tietojenkäsittelytieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(programmesBarChart.getByText('Datatieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(programmesBarChart.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
|
||||
|
||||
const row = page
|
||||
.getByTestId('programmes-before-or-after-data-table')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Matematiikan ja tilastotieteen maisteriohjelma' })
|
||||
await expect(row.getByText('33')).toBeVisible()
|
||||
await expect(row.getByText('20')).toBeVisible()
|
||||
await expect(row.getByText('17')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graduation times of master programmes', () => {
|
||||
test('are split into three graphs', async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matematiikan ja tilastotieteen maisteriohjelma' }).click()
|
||||
await page.getByTestId('year-toggle').click() // Tests are written for calendar year
|
||||
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-average').click()
|
||||
|
||||
const masterChart = page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.filter({ hasText: 'Master study right', hasNotText: 'Bachelor +' })
|
||||
await expect(masterChart).toBeVisible()
|
||||
await expect(masterChart.getByText('2 graduated')).toBeVisible()
|
||||
|
||||
await masterChart.getByText('2 graduated').hover()
|
||||
await expect(page.locator('.grad-vals').getByText('2 students graduated in year 2021')).toBeVisible()
|
||||
await expect(page.getByText('average study time: 4.5 semesters')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('0 graduated over year late')).toBeVisible()
|
||||
|
||||
const bachelorMasterChart = page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.filter({ hasText: 'Bachelor + master study right' })
|
||||
await expect(bachelorMasterChart).toBeVisible()
|
||||
await expect(bachelorMasterChart.getByText('11 graduated')).toBeVisible()
|
||||
|
||||
await bachelorMasterChart.getByText('11 graduated').hover()
|
||||
await expect(page.locator('.grad-vals').getByText('11 students graduated in year 2023')).toBeVisible()
|
||||
await expect(page.getByText('average study time: 11.18 semesters')).toBeVisible()
|
||||
await expect(page.getByText('4 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('6 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated over year late')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('StudyTracksAndClassStatisticsTab').click()
|
||||
})
|
||||
|
||||
test.describe('Info boxes', () => {
|
||||
test('Study track overview section', async ({ page }) => {
|
||||
await page.getByTestId('study-track-overview-info-box-button').hover()
|
||||
await expect(
|
||||
page.getByTestId('study-track-overview-info-box-content').getByText('Opiskelijat, joiden')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Progress of students section', async ({ page }) => {
|
||||
await page.getByTestId('progress-of-students-info-box-button').hover()
|
||||
await expect(
|
||||
page.getByTestId('progress-of-students-info-box-content').getByText('Kuvaa koulutusohjelmassa')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Average graduation times section', async ({ page }) => {
|
||||
await page.getByTestId('average-graduation-times-info-box-button').click()
|
||||
await expect(
|
||||
page.getByTestId('average-graduation-times-info-box-content').getByText('Yksittäinen palkki')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('All sections are visible', async ({ page }) => {
|
||||
await expect(page.getByTestId('study-track-selector-section')).toBeVisible()
|
||||
await expect(page.getByTestId('study-track-overview-section')).toBeVisible()
|
||||
await expect(page.getByTestId('progress-of-students-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Students of the degree programme are shown correctly', async ({ page }) => {
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Cancelled, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 1, 0, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 0, 29, 8, 0, 35, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 0, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 0, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 46, 40, 0, 0, 6, 40, 1, 0, 26, 20, 0, 45, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 1, 0, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 3, 0, 147, 82, 0, 224, 12],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
test('Years in the students table can be expanded and study track data will be shown', async ({ page }) => {
|
||||
const totalStats: [string, ...Array<number>] = ['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 0, 15, 15, 0, 29, 3]
|
||||
const table = page.getByTestId('study-tracks-and-class-statistics-data-table').locator('tbody')
|
||||
const row = table.locator('tr').filter({ hasText: '2020 - 2021' })
|
||||
|
||||
await expect(row.locator('td')).toHaveCount(14)
|
||||
await expect(row.locator('td')).toHaveText(totalStats.map(n => n.toString()))
|
||||
|
||||
const studyTrackStats: [string, ...Array<number>][] = [
|
||||
['Ekonometria (MAT-EKO)', 2, 2, 0, 0, 0, 2, 0, 0, 1, 1, 0, 2, 0],
|
||||
['Matematiikka (MAT-MAT)', 13, 10, 0, 0, 1, 12, 0, 0, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria (MAT-TIE)', 2, 1, 0, 0, 1, 1, 0, 0, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede (MAT-TIL)', 4, 4, 0, 0, 0, 4, 0, 0, 1, 3, 0, 4, 0],
|
||||
]
|
||||
|
||||
await row.getByTestId('show-study-tracks-button').click()
|
||||
|
||||
await Promise.all(
|
||||
studyTrackStats.map(async ([studyTrack, ...stats]) => {
|
||||
await expect(page.getByText(studyTrack)).toBeVisible()
|
||||
const studyTrackRow = table.locator('tr').filter({ hasText: studyTrack })
|
||||
|
||||
await expect(studyTrackRow.locator('td')).toHaveCount(14)
|
||||
await expect(studyTrackRow.locator('td')).toHaveText([studyTrack, ...stats].map(n => n.toString()))
|
||||
})
|
||||
)
|
||||
|
||||
await row.getByTestId('show-study-tracks-button').click()
|
||||
await Promise.all(
|
||||
studyTrackStats.map(async ([studyTrack]) => {
|
||||
await expect(page.getByText(studyTrack)).not.toBeVisible()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Population link button works for', () => {
|
||||
test('a single year', async ({ page }) => {
|
||||
await page.getByTestId('2023-population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2023 - 2024, 8 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('total', async ({ page }) => {
|
||||
await page.getByTestId('total-population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2017 - 2026, 227 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Links to class statistics page with study track info included work', async ({ page }) => {
|
||||
const table = page.getByTestId('study-tracks-and-class-statistics-data-table').locator('tbody')
|
||||
const row = table.locator('tr').filter({ hasText: '2022 - 2023' })
|
||||
|
||||
await row.getByTestId('show-study-tracks-button').click()
|
||||
await table.locator('tr').filter({ hasText: 'Matematiikka (MAT-MAT)' }).getByRole('link').click()
|
||||
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Studytrack MAT-MAT')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2022 - 2023, 26 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 3 out of 26 students')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Student progress data is shown correctly', async ({ page }) => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
|
||||
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
|
||||
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
|
||||
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
|
||||
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
test('Progress section', async ({ page }) => {
|
||||
const barChart = page.getByTestId('programme-progress-bar-chart-section')
|
||||
await expect(barChart.getByText('Less than 30 credits')).toBeVisible()
|
||||
await expect(barChart.getByText('30–60 credits')).toBeVisible()
|
||||
await expect(barChart.getByText('At least 180 credits')).toBeVisible()
|
||||
await expect(barChart.getByText('58.5%')).toBeVisible() // The percentage for total graduated, to check that the graph renders
|
||||
|
||||
await page.getByTestId('programme-progress-bar-chart-section').getByText('58.5%').hover()
|
||||
await expect(page.getByText('Graduated: 134')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Average graduation times section', () => {
|
||||
test('Shows correct data for all years and one year', async ({ page }) => {
|
||||
// NOTE: This test could be flaky. Many nth and first/last used
|
||||
await expect(page.getByTestId('unset-graduation-times-section').getByText('2019 - 2020')).toBeVisible()
|
||||
await page.getByTestId('unset-graduation-times-section').getByText('21', { exact: true }).hover()
|
||||
await expect(page.getByText('On time: 21')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.getByText('Click any bar on the chart to open a study track level breakdown for that year')
|
||||
).toBeVisible()
|
||||
await page.getByTestId('unset-graduation-times-section').getByText('15', { exact: true }).nth(1).click() // Skip legend
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.getByText('Click any bar on the chart to open a study track level breakdown for that year')
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect(page.getByText('Year 2020 - 2021 by start year')).toBeVisible()
|
||||
await expect(page.getByText('MAT-MAT')).toBeVisible()
|
||||
|
||||
await page.getByTestId('unsetBreakdownBarChartFaculty').getByText('9', { exact: true }).hover()
|
||||
await expect(page.getByText('Matematiikka')).toBeVisible()
|
||||
await expect(page.getByText('MAT-MAT').last()).toBeVisible()
|
||||
await expect(page.getByText('On time: 9')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows correct average', async ({ page }) => {
|
||||
await page.getByTestId('select-average').click()
|
||||
const section = page.getByTestId('average-graduation-times-section')
|
||||
|
||||
await expect(section.getByText('2020 - 2021')).toBeVisible()
|
||||
await section.getByText('19 graduated').hover()
|
||||
|
||||
await expect(page.getByText('From class of 2020 - 2021, 19/30 students have graduated')).toBeVisible()
|
||||
await expect(page.getByText('average study time: 5.79 semesters')).toBeVisible()
|
||||
await expect(page.getByText('15 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('3 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated over year late')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Study track can be changed', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// page.waitForTimeout('@stQuery')
|
||||
await expect(page.getByText('All students of the programme')).toBeVisible()
|
||||
await page.getByTestId('study-track-select').click()
|
||||
await expect(page.getByText('Ekonometria')).toBeVisible()
|
||||
await expect(page.getByText('Tietojenkäsittelyteoria')).toBeVisible()
|
||||
await expect(page.getByText('Tilastotiede')).toBeVisible()
|
||||
await page.getByText('Matematiikka').click()
|
||||
})
|
||||
|
||||
test('Students of the study track are shown correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('study-track-overview-section')
|
||||
.getByText('Students of the study track MAT-MAT by starting year')
|
||||
).toBeVisible()
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Cancelled, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 1, 0, 2, 1, 0, 3, 0],
|
||||
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 0, 3, 1, 0, 4, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 0, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 0, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 24, 21, 0, 0, 2, 22, 1, 0, 11, 13, 0, 24, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 1, 0, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 3, 0, 48, 41, 0, 88, 4],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
test('Links to class statistics page with study track info included work', async ({ page }) => {
|
||||
await page.getByTestId('2020-population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Studytrack MAT-MAT')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 10 out of 30 students')).toBeVisible()
|
||||
})
|
||||
|
||||
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
|
||||
test.fixme('Info message about missing progress stats is displayed', async ({ page }) => {
|
||||
await expect(page.getByText('Progress of students of the study track MAT-MAT by starting year')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Progress data is currently only available for all students of the degree programme.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Programme courses tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('ProgrammeCoursesTab').click()
|
||||
await expect(page.getByTestId('by-credit-type-section')).toBeVisible()
|
||||
await expect(page.getByTestId('by-credit-type-tab')).toBeVisible()
|
||||
await expect(page.getByTestId('by-semester-tab')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('By credit type tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await expect(page.getByText('Programme courses by credit type')).toBeVisible()
|
||||
await expect(page.getByText('Loading content')).not.toBeVisible()
|
||||
await page.getByText('Total students').click()
|
||||
})
|
||||
|
||||
test('shows correct data', async ({ page }) => {
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Johdatus yliopistomatematiikkaan', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 272, 253, 19, 240, 0, 4, 0, 0, 0, 1, 9].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11003', 'Raja-arvot', 270, 249, 21, 237, 0, 4, 0, 0, 0, 1, 8].map(n => n.toString())
|
||||
)
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Differentiaalilaskenta', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11004', 'Differentiaalilaskenta', 262, 248, 14, 230, 0, 4, 0, 0, 0, 1, 14].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('year toggle works', async ({ page }) => {
|
||||
await page.getByTestId('year-toggle').click()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Johdatus yliopistomatematiikkaan', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 278, 259, 19, 240, 0, 4, 0, 0, 0, 1, 15].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11003', 'Raja-arvot', 274, 253, 21, 237, 0, 4, 0, 0, 0, 1, 12].map(n => n.toString())
|
||||
)
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Differentiaalilaskenta', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11004', 'Differentiaalilaskenta', 264, 250, 14, 230, 0, 4, 0, 0, 0, 1, 16].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('time range selection works', async ({ page }) => {
|
||||
await page.getByTestId('from-year-select').click()
|
||||
await page.getByTestId('from-year-select-option-2020').click()
|
||||
await page.getByTestId('to-year-select').click()
|
||||
await page.getByTestId('to-year-select-option-2021').click()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Vektorianalyysi I', hasNotText: 'Avoin yo' })
|
||||
.filter({ hasNotText: 'Vektorianalyysi II' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT21003', 'Vektorianalyysi I', 104, 97, 7, 89, 0, 1, 0, 0, 0, 0, 10].map(n => n.toString())
|
||||
)
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Sarjat', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(['Course', 'MAT21002', 'Sarjat', 83, 77, 6, 70, 0, 1, 0, 0, 0, 0, 7].map(n => n.toString()))
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(['Course', 'MAT11003', 'Raja-arvot', 80, 73, 7, 69, 0, 2, 0, 0, 0, 1, 1].map(n => n.toString()))
|
||||
})
|
||||
|
||||
test("'Show credits/Show students' toggle works", async ({ page }) => {
|
||||
const headers = page.getByRole('columnheader')
|
||||
await expect(headers).toHaveCount(17)
|
||||
await expect(headers).toHaveText([
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total students',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed',
|
||||
'Not included in total or passed',
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Degree students',
|
||||
'Open university students (with hetu)',
|
||||
'Open university students (without hetu)',
|
||||
'Exchange students',
|
||||
'Other university students',
|
||||
'Separate studies',
|
||||
'Other students',
|
||||
'Students with transferred credits',
|
||||
])
|
||||
|
||||
await page.getByTestId('show-credits-students-toggle').click()
|
||||
await expect(headers).toHaveCount(13)
|
||||
await expect(headers).toHaveText([
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total credits',
|
||||
'Credits produced by',
|
||||
'Transferred credits',
|
||||
'Degree students',
|
||||
'Open university (hetu)',
|
||||
'Open university (no hetu)',
|
||||
'Exchange students',
|
||||
'Other universities',
|
||||
'Separate studies',
|
||||
'Other',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('By semester tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('by-semester-tab').click()
|
||||
})
|
||||
|
||||
// TODO: Implement proper tests
|
||||
test('can be opened', async ({ page }) => {
|
||||
await expect(page.getByText('Programme courses by semester')).toBeVisible()
|
||||
await expect(page.getByText('From')).toBeVisible()
|
||||
await expect(page.getByText('Until')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Degree courses tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('DegreeCoursesTab').click()
|
||||
})
|
||||
|
||||
test.describe('Curriculum section', () => {
|
||||
test('is shown', async ({ page }) => {
|
||||
await expect(page.getByTestId('curriculum-section')).toBeVisible()
|
||||
await expect(page.getByText('Select curriculum to edit:')).toBeVisible()
|
||||
await expect(page.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
|
||||
})
|
||||
test.skip('can be changed', () => {
|
||||
return
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Credit criteria section', () => {
|
||||
test('info box', async ({ page }) => {
|
||||
await expect(page.getByTestId('credit-criteria-section')).toBeVisible()
|
||||
await page.getByTestId('credit-criteria-info-box-button').click()
|
||||
await expect(
|
||||
page.getByTestId('credit-criteria-info-box-content').getByText('Here you can change')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('changing the credit criteria works', () => {
|
||||
// TODO: Test using the inputs
|
||||
// TODO: Button status
|
||||
// TODO: Test "previously set to _" text changing when saving
|
||||
})
|
||||
})
|
||||
|
||||
test('Degree course table', async ({ page }) => {
|
||||
await expect(page.getByTestId('degree-course-table')).toBeVisible()
|
||||
// TODO: Test clicking the arrow buttons
|
||||
// TODO: Test toggling visibility for MODULES and COURSES
|
||||
// TODO: Test changing criterion labels
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tags tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('TagsTab').click()
|
||||
|
||||
// We need to wait for tag page to load or there is no delete buttons
|
||||
await expect(page.getByText('Create new tag')).toBeVisible()
|
||||
|
||||
// Delete tags until none remain
|
||||
const deleteButtons = page.locator('[data-cy^=delete-tag-]')
|
||||
while ((await deleteButtons.count()) > 0) {
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
await deleteButtons.first().click()
|
||||
await page.getByTestId('confirm-delete-tag-button').click()
|
||||
|
||||
// Helps playwright to wait for the deleted tag acually being removed from DOM
|
||||
await expect(deleteButtons).toHaveCount(count - 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('info box', async ({ page }) => {
|
||||
await page.getByTestId('create-new-tag-info-box-button').click()
|
||||
await expect(page.getByTestId('create-new-tag-info-box-content').getByText('Here you can create')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Adding tags to populations and removing them works', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await expect(page.getByText('This degree programme does not have any tags yet')).toBeVisible()
|
||||
await page.getByTestId('tag-name-text-field').getByRole('textbox').fill(tagName)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteTag(page, tagName)
|
||||
await expect(page.getByText('This degree programme does not have any tags yet')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can create and delete tags without start year', async ({ page }) => {
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await expect(page.getByText('No associated start year')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can create and delete tags with start year', async ({ page }) => {
|
||||
await selectYear(page, 2022)
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await expect(page.getByText('Associated start year 2022')).toBeVisible()
|
||||
})
|
||||
|
||||
test.fixme('population link works', async ({ page }) => {
|
||||
await selectYear(page, 2022)
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await page.getByTestId('population-link-button').click()
|
||||
// FIXME: If no students are tagged with the tag, it shows "Invalid tag id"
|
||||
await expect(page.getByText(`Tagged with: ${tagName}`)).toBeVisible()
|
||||
await page.goBack()
|
||||
})
|
||||
|
||||
test('can create personal tags', async ({ page }) => {
|
||||
await page.getByTestId('personal-tag-toggle').click()
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await expect(page.getByTestId(`${tagName}-visibility-icon`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Adding tags to students and removing them works', () => {
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)!
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await init(page, '/study-programme', 'basic', context)
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('TagsTab').click()
|
||||
})
|
||||
|
||||
test('can add tags to students', async ({ page, context }) => {
|
||||
// NOTE: Navigating to each students' page takes time, fix?
|
||||
test.slow()
|
||||
test.setTimeout(120_000) // 2min
|
||||
|
||||
await page.getByTestId('tag-name-text-field').getByRole('textbox').fill(tagName)
|
||||
await selectYear(page, 2022)
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await page.getByTestId('add-students-button').click()
|
||||
await page.getByTestId('add-students-text-field').getByRole('textbox').fill(studentInput)
|
||||
await page.getByTestId('add-students-confirm-button').click()
|
||||
|
||||
await expect(page.getByText('Students added to tag')).toBeVisible()
|
||||
|
||||
await page.getByTestId('population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2022 - 2023')).toBeVisible()
|
||||
await expect(page.getByText(`Tagged with: ${tagName}`)).toBeVisible()
|
||||
await expect(page.getByText('Students (5)')).toBeVisible()
|
||||
|
||||
await page.getByText('Students (5)').click()
|
||||
|
||||
await Promise.all(studentNumbers.map(studentNumber => expect(page.getByText(studentNumber)).toBeVisible()))
|
||||
|
||||
await Promise.all(
|
||||
studentNumbers.map(async studentNumber => {
|
||||
const studentPage = await context.newPage()
|
||||
await studentPage.goto(`/students/${studentNumber}`)
|
||||
await expect(studentPage.getByRole('heading', { name: 'Tags' })).toBeVisible()
|
||||
await expect(studentPage.getByText(tagName)).toBeVisible()
|
||||
await studentPage.close()
|
||||
})
|
||||
)
|
||||
|
||||
await page.goBack()
|
||||
await deleteTag(page, tagName)
|
||||
})
|
||||
|
||||
test('deleting a tag from tag view also removes it from students', async ({ page, context }) => {
|
||||
// NOTE: Navigating to each students' page takes time, fix?
|
||||
test.slow()
|
||||
test.setTimeout(180_000) // 4min
|
||||
|
||||
await expect(page.getByText(tagName)).not.toBeVisible()
|
||||
await Promise.all(
|
||||
studentNumbers.map(async studentNumber => {
|
||||
const studentPage = await context.newPage()
|
||||
await studentPage.goto(`/students/${studentNumber}`)
|
||||
await expect(studentPage.getByRole('heading', { name: 'Tags' })).not.toBeVisible()
|
||||
await expect(studentPage.getByText(tagName)).not.toBeVisible()
|
||||
// await studentPage.close()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('IAM user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme', 'onlyiamrights')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
})
|
||||
|
||||
test.skip('year selector', () => {
|
||||
// TODO: Implement this test
|
||||
// Things to test:
|
||||
// - Clicking the button works
|
||||
// - Selecting a study track works
|
||||
// - The button is disabled if only one year is selected
|
||||
// - The slider is not visible if the user does not have correct programme rights
|
||||
})
|
||||
|
||||
test('can access programme and correct tabs are visible', async ({ page }) => {
|
||||
await expect(page.getByText('Basic information')).toBeVisible()
|
||||
await expect(page.getByText('Study tracks and class statistics')).toBeVisible()
|
||||
await expect(page.getByText('Update statistics')).not.toBeVisible()
|
||||
await expect(page.getByText('Degree courses')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('can access basic information', async ({ page }) => {
|
||||
await page.getByTestId('BasicInformationTab').click()
|
||||
await expect(page.getByTestId('students-of-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('credits-produced-by-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('graduated-and-thesis-writers-of-the-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
await expect(page.getByTestId('programmes-before-or-after-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can access study tracks', async ({ page }) => {
|
||||
await page.getByTestId('StudyTracksAndClassStatisticsTab').click()
|
||||
await expect(page.getByTestId('study-track-overview-section')).toBeVisible()
|
||||
await expect(page.getByTestId('progress-of-students-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test("doesn't see other tabs", async ({ page }) => {
|
||||
await expect(page.getByTestId('DegreeCoursesTab')).not.toBeVisible()
|
||||
await expect(page.getByTestId('TagsTab')).not.toBeVisible()
|
||||
await expect(page.getByTestId('UpdateStatisticsTab')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, Page, test } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const facultyName = 'Matemaattis-luonnontieteellinen tiedekunta'
|
||||
|
||||
test.use({ acceptDownloads: true })
|
||||
|
||||
const openFaculty = async (page: Page, userId?: 'admin' | 'basic') => {
|
||||
await init(page, '/faculties', userId)
|
||||
await page.getByRole('link', { name: facultyName }).click()
|
||||
}
|
||||
|
||||
test.describe('Faculty statistics', () => {
|
||||
test.describe('Faculty list', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/faculties')
|
||||
})
|
||||
|
||||
test('contains faculty names and faculty codes', async ({ page }) => {
|
||||
const faculties = [
|
||||
['Teologinen tiedekunta', 'H10'],
|
||||
['Oikeustieteellinen tiedekunta', 'H20'],
|
||||
['Lääketieteellinen tiedekunta', 'H30'],
|
||||
['Humanistinen tiedekunta', 'H40'],
|
||||
['Matemaattis-luonnontieteellinen tiedekunta', 'H50'],
|
||||
['Farmasian tiedekunta', 'H55'],
|
||||
['Bio- ja ympäristötieteellinen tiedekunta', 'H57'],
|
||||
['Kasvatustieteellinen tiedekunta', 'H60'],
|
||||
['Valtiotieteellinen tiedekunta', 'H70'],
|
||||
['Svenska social- och kommunalhögskolan', 'H74'],
|
||||
['Maatalous-metsätieteellinen tiedekunta', 'H80'],
|
||||
['Eläinlääketieteellinen tiedekunta', 'H90'],
|
||||
]
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Faculties', exact: true })).toBeVisible()
|
||||
for (const [name, code] of faculties) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(code, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('contains a working link to faculty page', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Teologinen tiedekunta' }).click()
|
||||
await expect(page).toHaveURL('/faculties/hy-org-1000000580')
|
||||
await expect(page.getByText('Teologinen tiedekunta', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('H10', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Basic information tab', () => {
|
||||
test('shows the correct tabs for an admin user', async ({ page }) => {
|
||||
await openFaculty(page, 'admin')
|
||||
const tabs = page.getByTestId('faculty-tabs')
|
||||
await expect(tabs).toContainText('Basic information')
|
||||
await expect(tabs).toContainText('Students by starting year')
|
||||
await expect(tabs).toContainText('Graduation times')
|
||||
await expect(tabs).toContainText('Update statistics')
|
||||
})
|
||||
|
||||
test.describe('Basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openFaculty(page)
|
||||
})
|
||||
|
||||
test('info boxes contain the correct information', async ({ page }) => {
|
||||
const infoBoxes = [
|
||||
['students-of-the-faculty-info-box-button', 'students-of-the-faculty-info-box-content', 'Taulukon luvut on'],
|
||||
[
|
||||
'graduated-of-the-faculty-info-box-button',
|
||||
'graduated-of-the-faculty-info-box-content',
|
||||
'Sisältää kyseisenä',
|
||||
],
|
||||
[
|
||||
'thesis-writers-of-the-faculty-info-box-button',
|
||||
'thesis-writers-of-the-faculty-info-box-content',
|
||||
'Sisältää kyseisenä',
|
||||
],
|
||||
[
|
||||
'credits-produced-by-the-faculty-info-box-button',
|
||||
'credits-produced-by-the-faculty-info-box-content',
|
||||
'Sisältää opintopisteet',
|
||||
],
|
||||
]
|
||||
|
||||
for (const [button, content, text] of infoBoxes) {
|
||||
await page.getByTestId(button).click()
|
||||
await expect(page.getByTestId(content)).toContainText(text)
|
||||
}
|
||||
})
|
||||
|
||||
test('shows all graphs and tables', async ({ page }) => {
|
||||
const sections = [
|
||||
'students-of-the-faculty-line-graph-section',
|
||||
'students-of-the-faculty-interactive-data-table',
|
||||
'graduated-of-the-faculty-line-graph-section',
|
||||
'graduated-of-the-faculty-interactive-data-table',
|
||||
'thesis-writers-of-the-faculty-line-graph-section',
|
||||
'thesis-writers-of-the-faculty-interactive-data-table',
|
||||
'credits-produced-by-the-faculty-stacked-bar-chart-section',
|
||||
'credits-produced-by-the-faculty-interactive-data-table',
|
||||
]
|
||||
|
||||
for (const section of sections) await expect(page.getByTestId(section)).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the correct tabs', async ({ page }) => {
|
||||
const tabs = page.getByTestId('faculty-tabs')
|
||||
await expect(tabs).toContainText('Basic information')
|
||||
await expect(tabs).toContainText('Students by starting year')
|
||||
await expect(tabs).toContainText('Graduation times')
|
||||
await expect(tabs).not.toContainText('Update statistics')
|
||||
})
|
||||
|
||||
test('year toggle works', async ({ page }) => {
|
||||
const tables = [
|
||||
'credits-produced-by-the-faculty-interactive-data-table',
|
||||
'thesis-writers-of-the-faculty-interactive-data-table',
|
||||
'students-of-the-faculty-interactive-data-table',
|
||||
'graduated-of-the-faculty-interactive-data-table',
|
||||
]
|
||||
for (const table of tables) await expect(page.getByTestId(table)).toContainText('2022 - 2023')
|
||||
|
||||
await page.getByTestId('year-toggle').click()
|
||||
for (const table of tables) await expect(page.getByTestId(table)).toContainText('2022')
|
||||
})
|
||||
|
||||
test('programme toggle works', async ({ page }) => {
|
||||
await expect(page.getByTestId('faculty-programmes-shown-info')).not.toBeVisible()
|
||||
await page.getByTestId('programme-toggle').click()
|
||||
await expect(page.getByTestId('faculty-programmes-shown-info')).toBeVisible()
|
||||
})
|
||||
|
||||
test('study rights toggle works', async ({ page }) => {
|
||||
await expect(page.getByTestId('faculty-exclude-specials-info')).not.toBeVisible()
|
||||
await page.getByTestId('study-right-toggle').click()
|
||||
await expect(page.getByTestId('faculty-exclude-specials-info')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Students by starting year tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openFaculty(page)
|
||||
await page.getByText('Students by starting year', { exact: true }).click()
|
||||
})
|
||||
|
||||
test.skip('export button downloads an Excel file', async ({ page }) => {
|
||||
// Firefox does not trigger playwright's download event when downloading a file
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByTestId('faculty-student-table-export-button').click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toMatch(/^oodikone_H50_programme_stats_\d{4}-\d{2}-\d{2}\.xlsx$/)
|
||||
})
|
||||
|
||||
test('export button exists and works', async ({ page }) => {
|
||||
const exportButton = page.getByTestId('faculty-student-table-export-button')
|
||||
await expect(exportButton).toBeVisible()
|
||||
await expect(exportButton).toBeEnabled()
|
||||
await exportButton.click()
|
||||
})
|
||||
|
||||
test('info box contains the correct information', async ({ page }) => {
|
||||
await page.getByTestId('faculty-student-table-info-box-button').click()
|
||||
await expect(page.getByTestId('faculty-student-table-info-box-content')).toContainText('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
test('percentage toggle works', async ({ page }) => {
|
||||
const table = page.getByTestId('faculty-student-stats-table')
|
||||
await expect(table).toBeVisible()
|
||||
await expect(table).not.toContainText('92.3 %')
|
||||
await page.getByTestId('percentage-toggle').click()
|
||||
await expect(table).toContainText('92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graduation times tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openFaculty(page)
|
||||
await page.getByText('Graduation times', { exact: true }).click()
|
||||
})
|
||||
|
||||
test('info box contains the correct information', async ({ page }) => {
|
||||
await page.getByTestId('average-graduation-times-info-box-button').click()
|
||||
await expect(page.getByTestId('average-graduation-times-info-box-content')).toContainText(
|
||||
'Opiskelijoiden keskimääräiset'
|
||||
)
|
||||
})
|
||||
|
||||
test('graphs are visible', async ({ page }) => {
|
||||
await expect(page.getByTestId('bachelor-graduation-times-section')).toBeVisible()
|
||||
await expect(page.getByTestId('master-graduation-times-section')).toBeVisible()
|
||||
await expect(page.getByTestId('doctor-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const checkFilteredStudentCount = async (page: Page, count: number) => {
|
||||
await expect(page.getByText(`Students (${count})`)).toBeVisible()
|
||||
}
|
||||
|
||||
const notYetImplemented = () => {
|
||||
test.skip(true, 'TODO')
|
||||
}
|
||||
|
||||
const selectDropdownOption = async (page: Page, selector: string, option: string | number) => {
|
||||
// Only open the filter card if it is closed
|
||||
const dropdown = page.getByTestId(`${selector}-selector`)
|
||||
if (!(await dropdown.isVisible())) {
|
||||
await page.getByTestId(`${selector}-header`).click()
|
||||
await expect(dropdown).toBeVisible()
|
||||
}
|
||||
|
||||
await dropdown.click()
|
||||
|
||||
const options = page.getByRole('listbox').getByRole('option')
|
||||
if (typeof option === 'number') await options.nth(option).click()
|
||||
else await options.filter({ hasText: option }).click()
|
||||
await page.keyboard.press('Escape') // Close the search box
|
||||
}
|
||||
|
||||
const setRange = async (page: Page, filter: string, min: number, max: number, expected: number) => {
|
||||
const card = page.getByTestId(`${filter}-filter-card`)
|
||||
await card.getByTestId('FilterRangeStart').locator('input').fill(String(min))
|
||||
await card.getByTestId('FilterRangeEnd').locator('input').fill(String(max))
|
||||
await checkFilteredStudentCount(page, expected)
|
||||
}
|
||||
|
||||
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultMScStudents = 26
|
||||
|
||||
test.describe("Population statistics with a master's programme", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, pathToMathMSc2020)
|
||||
})
|
||||
|
||||
test('Study track filter works', async ({ page }) => {
|
||||
await selectDropdownOption(page, 'studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)')
|
||||
await checkFilteredStudentCount(page, 15)
|
||||
await page.getByTestId('studyTrackFilter-clear').click()
|
||||
await checkFilteredStudentCount(page, defaultMScStudents)
|
||||
})
|
||||
|
||||
test('Study right type filter works', async ({ page }) => {
|
||||
const filter = page.getByTestId('studyRightTypeFilter-header')
|
||||
await filter.click()
|
||||
for (const [value, count] of [
|
||||
['Bachelor + master', 24],
|
||||
['Master only', 2],
|
||||
['All', defaultMScStudents],
|
||||
] as const) {
|
||||
const option = page
|
||||
.getByTestId('studyRightTypeFilter-filter-card')
|
||||
.getByRole('radio', { name: value, exact: true })
|
||||
await expect(option).not.toBeChecked()
|
||||
await option.click()
|
||||
await expect(option).toBeChecked()
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
await filter.click()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Population Statistics', () => {
|
||||
test.describe.skip('TODO', () => {
|
||||
test('Study right type filter is not visible', async ({ page }) => {
|
||||
await init(page, '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING')
|
||||
await expect(page.getByText('Study right type')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Transfer filter works and is set to "not transferred" by default', notYetImplemented)
|
||||
test('Graduation filter works', notYetImplemented)
|
||||
test('Enrollment filter works', notYetImplemented)
|
||||
test('Credit filter works', notYetImplemented)
|
||||
test('Age filter works', notYetImplemented)
|
||||
test('Gender filter works', notYetImplemented)
|
||||
test('Starting year filter works', notYetImplemented)
|
||||
test('Admission type filter works', notYetImplemented)
|
||||
test('Courses filter works', notYetImplemented)
|
||||
test('Filter combinations work', notYetImplemented)
|
||||
test('"Reset All Filters" button works', notYetImplemented)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Population Statistics with Bachelor + Master', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(
|
||||
page,
|
||||
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
|
||||
)
|
||||
})
|
||||
|
||||
test('Graduation filter works', async ({ page }) => {
|
||||
await page.getByTestId('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
for (const [value, count] of [
|
||||
["Graduated with Bachelor's", 42],
|
||||
["Graduated with Master's", 18],
|
||||
["Not graduated with Bachelor's", 5],
|
||||
["Not graduated with Master's", 29],
|
||||
] as const) {
|
||||
const option = page
|
||||
.getByTestId('graduatedFromProgrammeFilter-filter-card')
|
||||
.getByRole('radio', { name: value, exact: true })
|
||||
await expect(option).not.toBeChecked()
|
||||
await option.click()
|
||||
await expect(option).toBeChecked()
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
})
|
||||
|
||||
test('Study right status filter works', async ({ page }) => {
|
||||
await page.getByTestId('studyRightStatusFilter-header').click()
|
||||
for (const [value, count] of [
|
||||
['Active Bachelor study right', 0],
|
||||
['Active Master study right', 0],
|
||||
['Passive Bachelor study right', 5],
|
||||
['Passive Master study right', 24],
|
||||
] as const) {
|
||||
const option = page
|
||||
.getByTestId('studyRightStatusFilter-filter-card')
|
||||
.getByRole('radio', { name: value, exact: true })
|
||||
await expect(option).not.toBeChecked()
|
||||
await option.click()
|
||||
await expect(option).toBeChecked()
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Course Statistics', () => {
|
||||
test('Grade filter works', notYetImplemented)
|
||||
test('Programme filter works and defaults to "Attainment"', notYetImplemented)
|
||||
test('Age filter works', notYetImplemented)
|
||||
test('Gender filter works', notYetImplemented)
|
||||
test('Starting year filter works', notYetImplemented)
|
||||
test('Filter combinations work', notYetImplemented)
|
||||
})
|
||||
|
||||
test.describe('Custom Population Statistics', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/custompopulation')
|
||||
const students = ['433237', '457144', '458090', '465136', '474270', '479440', '470391', '474789']
|
||||
await page.getByTestId('student-number-input').getByRole('textbox').pressSequentially(students.join('\n'))
|
||||
await page.getByTestId('search-button').click()
|
||||
await expect(page.getByText('Credit accumulation')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Age filter works', async ({ page }) => {
|
||||
await page.getByTestId('ageFilter-header').click()
|
||||
await setRange(page, 'ageFilter', 24, 28, 4)
|
||||
})
|
||||
|
||||
test('Programme filter works and defaults to "Active Study Right"', async ({ page }) => {
|
||||
await page.getByTestId('programmeFilter-header').click()
|
||||
|
||||
await expect(page.getByTestId('programmeFilter-mode-selector').getByRole('combobox')).toHaveText(
|
||||
'Active Study Right'
|
||||
)
|
||||
await selectDropdownOption(page, 'programmeFilter', 'Matemaattisten tieteiden kandiohjelma')
|
||||
await checkFilteredStudentCount(page, 5)
|
||||
})
|
||||
|
||||
test('Gender filter works', async ({ page }) => {
|
||||
for (const [value, count] of [
|
||||
['Female (5)', 5],
|
||||
['Male (3)', 3],
|
||||
['Other (0)', 0],
|
||||
['Unknown (0)', 0],
|
||||
] as const) {
|
||||
await selectDropdownOption(page, 'genderFilter', value)
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
})
|
||||
|
||||
test('Starting year filter works', async ({ page }) => {
|
||||
await selectDropdownOption(page, 'startYearAtUniFilter', 0)
|
||||
await checkFilteredStudentCount(page, 2)
|
||||
})
|
||||
|
||||
test('Courses filter works', async ({ page }) => {
|
||||
const filter = page.getByTestId('courseFilter-header')
|
||||
await filter.click()
|
||||
await page.getByRole('combobox').fill('MAT11005 - Integraalilaskenta')
|
||||
await page.getByRole('option', { name: 'MAT11005 - Integraalilaskenta' }).click()
|
||||
await checkFilteredStudentCount(page, 7)
|
||||
await page.getByTestId('courseFilter-MAT11005-selector').click()
|
||||
await page.getByRole('option', { name: 'Passed' }).click()
|
||||
await checkFilteredStudentCount(page, 6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, test, Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const chooseSemester = async (page: Page, semester: string, fromOrTo: 'from' | 'to') => {
|
||||
await page.getByTestId(`semester-${fromOrTo}`).click()
|
||||
await page.getByTestId(`select-opt-${semester}`).click()
|
||||
await expect(page.getByTestId(`select-opt-${semester}`)).not.toHaveClass(/visible/)
|
||||
}
|
||||
|
||||
const checkNumbers = async (page: Page, numbers: number[], numberOfColumns: number, mode: string) => {
|
||||
const totalRow = page.getByTestId(`ooditable-${mode}`).locator('table tbody tr').first()
|
||||
const cells = totalRow.locator('td')
|
||||
|
||||
await expect(cells).toHaveCount(numberOfColumns)
|
||||
await expect(cells).toHaveText(['Total' + 'All courses total'].concat(numbers.map(number => String(number))))
|
||||
}
|
||||
|
||||
test.describe('When language center is opened', () => {
|
||||
test.describe('as an admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/languagecenterview', 'admin')
|
||||
await page.getByTestId('completions-button').click()
|
||||
})
|
||||
|
||||
test.describe('Faculties tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await chooseSemester(page, 'Syksy 2017', 'from')
|
||||
await chooseSemester(page, 'Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
test('Initial view is correct', async ({ page }) => {
|
||||
await expect(page.getByText('All courses total')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('cell', { name: 'AYKK-ENG Academic and Professional Communication in English' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('AYKK-RUKIRJ')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Faculties tab shows numbers', async ({ page }) => {
|
||||
await checkNumbers(page, [2076, 36, 9, 34, 759, 15, 40, 6, 51, 1, 24, 1059, 42, 0], 15, 'faculties')
|
||||
})
|
||||
|
||||
test('Faculties tab "exceeding" button works', async ({ page }) => {
|
||||
await page.getByTestId('difference-button').click()
|
||||
await checkNumbers(page, [66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 1], 15, 'faculties')
|
||||
})
|
||||
|
||||
test('Faculties tab semester selector changes numbers', async ({ page }) => {
|
||||
await chooseSemester(page, 'Syksy 2020', 'from')
|
||||
await checkNumbers(page, [1184, 28, 6, 28, 499, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Semester tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByText('By semesters').click()
|
||||
await chooseSemester(page, 'Syksy 2017', 'from')
|
||||
await chooseSemester(page, 'Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
test('Semester tab shows numbers', async ({ page }) => {
|
||||
await checkNumbers(page, [2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with a user with no rights', () => {
|
||||
test('"Access denied" is shown', async ({ page }) => {
|
||||
await init(page, '/languagecenterview', 'norights')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
|
||||
await expect(page.getByText("You don't currently have permission to view this page.")).toBeVisible()
|
||||
|
||||
await page.getByText('Special populations').click()
|
||||
await expect(page.getByText('Language center view')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
type CourseData = [
|
||||
courseOrModule: string,
|
||||
code: string,
|
||||
name: string,
|
||||
total: number,
|
||||
passed: number,
|
||||
notCompleted: number,
|
||||
failed: number,
|
||||
enrolledNoGrade: number,
|
||||
]
|
||||
|
||||
const courseData: CourseData[] = [
|
||||
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 272, 253, 1 + 18, 1, 18],
|
||||
['Course', 'MAT11003', 'Raja-arvot', 270, 249, 1 + 20, 1, 20],
|
||||
['Course', 'MAT11004', 'Differentiaalilaskenta', 262, 248, 1 + 13, 1, 13],
|
||||
['Course', 'MAT21002', 'Sarjat', 256, 250, 0 + 6, 0, 6],
|
||||
['Course', 'MAT11005', 'Integraalilaskenta', 251, 234, 0 + 17, 0, 17],
|
||||
['Course', 'MAT11002', 'Lineaarialgebra ja matriisilaskenta I', 247, 242, 1 + 4, 1, 4],
|
||||
['Course', 'MAT12003', 'Todennäköisyyslaskenta I', 234, 227, 3 + 4, 3, 4],
|
||||
['Course', 'MAT21001', 'Lineaarialgebra ja matriisilaskenta II', 230, 216, 4 + 10, 4, 10],
|
||||
['Course', 'MAT21003', 'Vektorianalyysi I', 228, 202, 3 + 23, 3, 23],
|
||||
['Course', 'MAT20005', 'Akateemiset taidot', 201, 178, 0 + 23, 0, 23],
|
||||
['Module', 'MAT110', 'Matematiikka, perusopinnot', 195, 195, 0 + 0, 0, 0],
|
||||
['Course', 'MAT21014', 'Johdatus logiikkaan I', 188, 170, 0 + 18, 0, 18],
|
||||
]
|
||||
|
||||
const yearlyData = [
|
||||
[2017, 1, 0, 1, 0, 1 + 0],
|
||||
[2018, 31, 27, 4, 0, 4 + 0],
|
||||
[2019, 58, 56, 2, 0, 2 + 0],
|
||||
[2020, 63, 62, 1, 0, 1 + 0],
|
||||
[2021, 41, 35, 0, 6, 0 + 6],
|
||||
[2022, 28, 21, 0, 7, 0 + 7],
|
||||
[2023, 21, 1, 0, 20, 0 + 20],
|
||||
] as const
|
||||
|
||||
// Copied from services/backend/src/services/studyProgramme/studyProgrammeHelpers.ts (almost)
|
||||
const getPercentage = (value: any, total: any) => {
|
||||
if (typeof value !== 'number' || typeof total !== 'number') return 'NA'
|
||||
if (total === 0) return 'NA'
|
||||
if (value === 0) return '0.00 %'
|
||||
return `${((value / total) * 100).toFixed(2)} %`
|
||||
}
|
||||
|
||||
const selectCourseStatus = async (page: Page, courseCode: string, status: string) => {
|
||||
await page.getByTestId(`courseFilter-${courseCode}-selector`).click()
|
||||
await page.getByRole('listbox').getByRole('option', { name: status }).click()
|
||||
}
|
||||
|
||||
const selectYear = async (page: Page, selector: string, option: string) => {
|
||||
await page.getByTestId(selector).click()
|
||||
await page.getByTestId(option).click()
|
||||
}
|
||||
|
||||
test.describe('Numbers should match between', () => {
|
||||
test.slow()
|
||||
test.setTimeout(180_000) // 3min
|
||||
|
||||
test.describe('Programme courses, Course statistics and Course population (no substitutions, all years)', () => {
|
||||
test('in Programme courses', async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('ProgrammeCoursesTab').click()
|
||||
await expect(page.getByText('Programme courses by credit type')).toBeVisible()
|
||||
|
||||
await page.getByRole('columnheader', { name: 'Total students' }).click()
|
||||
|
||||
const rows = page.locator('tbody tr')
|
||||
for (const [index, course] of courseData.entries()) {
|
||||
const row = rows.nth(index)
|
||||
// Not optimal, but not checking all locator("td") matches directly with toHaveText is hard.
|
||||
for (const [fieldIndex, field] of course.slice(0, 6).entries()) {
|
||||
await expect(row.locator('td').nth(fieldIndex)).toHaveText(String(field))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course statistics', async ({ page }) => {
|
||||
for (const [_courseOrModule, code, name, total, passed, _notCompleted, failed, enrolledNoGrade] of courseData) {
|
||||
await init(page, `/coursestatistics?courseCodes=%5B%22${code}%22%5D&combineSubstitutions=false`, 'basic')
|
||||
await expect(page).toHaveURL(
|
||||
url => url.pathname === '/coursestatistics' && url.searchParams.get('courseCodes') === JSON.stringify([code])
|
||||
)
|
||||
await expect(page.getByText('Course statistics')).toBeVisible()
|
||||
await expect(page.getByText(name)).toBeVisible()
|
||||
|
||||
await selectYear(page, 'FromYearSelector', 'FromYearSelectorOption2017-2018')
|
||||
|
||||
const row = page.locator('tbody tr').first()
|
||||
await expect(row.locator('td')).toHaveText(
|
||||
[
|
||||
'Total',
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
enrolledNoGrade,
|
||||
getPercentage(passed, total),
|
||||
getPercentage(failed + enrolledNoGrade, total),
|
||||
].map(String)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course population', async ({ page }) => {
|
||||
for (const [_courseOrModule, code, name, total, passed, _notCompleted, failed, enrolledNoGrade] of courseData) {
|
||||
await init(
|
||||
page,
|
||||
`/coursepopulation?from=${2017 - 1949}&to=${2023 - 1949}&coursecodes=%5B%22${code}%22%5D&includeSubstitutions=false`,
|
||||
'basic'
|
||||
)
|
||||
await expect(page.getByText(name)).toBeVisible()
|
||||
|
||||
await page.getByTestId('courseFilter-filter-card').click()
|
||||
await page.getByTestId('courseFilter-selector').click()
|
||||
await page
|
||||
.getByTestId('courseFilter-popper')
|
||||
.getByRole('option', { name: `${code} - ${name}` })
|
||||
.click()
|
||||
|
||||
for (const [status, expected] of [
|
||||
['All', total],
|
||||
['Passed', passed],
|
||||
['Failed', failed],
|
||||
['Enrolled, No Grade', enrolledNoGrade],
|
||||
] as const) {
|
||||
await selectCourseStatus(page, code, status)
|
||||
await expect(page.getByText(`Students (${expected})`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Programme courses, Course statistics and Course population (no substitutions, single years, single course)', () => {
|
||||
test('in Programme courses', async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('ProgrammeCoursesTab').click()
|
||||
|
||||
for (const [year, total, passed, _failed, _enrolledNoGrade, notCompleted] of yearlyData) {
|
||||
await selectYear(page, 'to-year-select', `to-year-select-option-${year}`)
|
||||
await selectYear(page, 'from-year-select', `from-year-select-option-${year}`)
|
||||
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'MAT21003' })
|
||||
await expect(row.locator('td').nth(3)).toContainText(String(total))
|
||||
await expect(row.locator('td').nth(4)).toContainText(String(passed))
|
||||
await expect(row.locator('td').nth(5)).toContainText(String(notCompleted))
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course statistics', async ({ page }) => {
|
||||
for (const [year, total, passed, failed, enrolledNoGrade] of yearlyData) {
|
||||
await init(page, '/coursestatistics?courseCodes=%5B%22MAT21003%22%5D&combineSubstitutions=false', 'basic')
|
||||
const yearString = `${year}-${year + 1}`
|
||||
|
||||
await selectYear(page, 'ToYearSelector', `ToYearSelectorOption${yearString}`)
|
||||
await selectYear(page, 'FromYearSelector', `FromYearSelectorOption${yearString}`)
|
||||
await expect(page.getByText('Student statistics')).toBeVisible()
|
||||
await expect(page.locator('tbody tr')).toHaveCount(2)
|
||||
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'Total' })
|
||||
|
||||
await expect(row.locator('td')).toHaveText(
|
||||
[
|
||||
'Total',
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
enrolledNoGrade,
|
||||
getPercentage(passed, total),
|
||||
getPercentage(failed + enrolledNoGrade, total),
|
||||
].map(String)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course population', async ({ page }) => {
|
||||
for (const [year, _total, passed, failed, enrolledNoGrade] of yearlyData) {
|
||||
await init(
|
||||
page,
|
||||
`/coursepopulation?from=${year - 1949}&to=${year - 1949}&coursecodes=%5B%22MAT21003%22%5D&includeSubstitutions=false`,
|
||||
'basic'
|
||||
)
|
||||
await expect(page.getByText('Vektorianalyysi I')).toBeVisible()
|
||||
|
||||
await page.getByTestId('courseFilter-filter-card').click()
|
||||
await page.getByTestId('courseFilter-selector').click()
|
||||
await page
|
||||
.getByTestId('courseFilter-popper')
|
||||
.getByRole('option', { name: 'MAT21003 - Vektorianalyysi I' })
|
||||
.click()
|
||||
|
||||
for (const [status, expected] of [
|
||||
['All', _total],
|
||||
['Passed', passed],
|
||||
['Failed', failed],
|
||||
['Enrolled, No Grade', enrolledNoGrade],
|
||||
] as const) {
|
||||
await selectCourseStatus(page, 'MAT21003', status)
|
||||
await expect(page.getByText(`Students (${expected})`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,493 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
// Now "Class statistics" in UI
|
||||
const selectStudyProgramme = async (page: Page, programme: string) => {
|
||||
await expect(page.getByTestId('population-programme-selector').locator('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Select degree programme'
|
||||
)
|
||||
await page.getByTestId('population-programme-selector-parent').click()
|
||||
await page.getByTestId('population-programme-selector-parent').getByText(programme).click()
|
||||
}
|
||||
|
||||
const selectStudyTrack = async (page: Page, studyTrack: string) => {
|
||||
await expect(page.getByTestId('population-studytrack-selector').locator('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Select study track'
|
||||
)
|
||||
await page.getByTestId('population-studytrack-selector-parent').click()
|
||||
await page.getByTestId('population-studytrack-selector-parent').getByText(studyTrack).click()
|
||||
}
|
||||
|
||||
const getPath = (programme: string) => {
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
|
||||
}
|
||||
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
|
||||
test.describe('Population statistics tests', () => {
|
||||
test.describe('When using basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/populations')
|
||||
})
|
||||
|
||||
test.describe('Population search', () => {
|
||||
test('Info box works', async ({ page }) => {
|
||||
await expect(page.getByTestId('PopulationSearch-info-box-content')).not.toBeVisible()
|
||||
await page.getByTestId('PopulationSearch-info-box-button').hover()
|
||||
await expect(page.getByTestId('PopulationSearch-info-box-content')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByTestId('PopulationSearch-info-box-content').getByText('Tässä osiossa voi tarkastella')
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByTestId('PopulationSearch-info-box-button').dispatchEvent('mouseout')
|
||||
await expect(page.getByTestId('PopulationSearch-info-box-content')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Form is usable', async ({ page }) => {
|
||||
await expect(page.getByText('Search for class')).toBeVisible()
|
||||
await expect(page.getByText('See class')).toBeDisabled()
|
||||
|
||||
const yearSelect = page.getByTestId('population-year-selector')
|
||||
const yearDecrement = page.getByTestId('population-year-decrement')
|
||||
const yearIncrement = page.getByTestId('population-year-increment')
|
||||
|
||||
await yearSelect.click()
|
||||
await page.getByText('2018 - 2019').click()
|
||||
await expect(yearSelect.getByText('2018 - 2019')).toBeVisible()
|
||||
|
||||
await yearDecrement.click()
|
||||
await expect(yearSelect.getByText('2017 - 2018')).toBeVisible()
|
||||
|
||||
await yearIncrement.click()
|
||||
await expect(yearSelect.getByText('2018 - 2019')).toBeVisible()
|
||||
|
||||
await expect(page.getByTestId('population-studytrack-selector').locator('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'No study tracks available'
|
||||
)
|
||||
|
||||
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
await expect(page.getByText('See class')).toBeEnabled()
|
||||
|
||||
await selectStudyTrack(page, 'Matematiikka ja soveltava matematiikka')
|
||||
await expect(page.getByText('See class')).toBeEnabled()
|
||||
})
|
||||
|
||||
test.describe('Correct population is shown for programme', () => {
|
||||
test('without study tracks', async ({ page }) => {
|
||||
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
|
||||
// Select year 2020 - 2021
|
||||
const yearSelect = page.getByTestId('population-year-selector')
|
||||
await yearSelect.click()
|
||||
await page.getByText('2020 - 2021').click()
|
||||
|
||||
await page.getByText('See class').click()
|
||||
await expect(page.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 26 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 26 out of 26 students')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Studytrack MAST-MSM')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('with study tracks', async ({ page }) => {
|
||||
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
|
||||
// Select year 2020 - 2021
|
||||
const yearSelect = page.getByTestId('population-year-selector')
|
||||
await yearSelect.click()
|
||||
await page.getByText('2020 - 2021').click()
|
||||
|
||||
await selectStudyTrack(page, 'Matematiikka ja soveltava matematiikka')
|
||||
|
||||
await page.getByText('See class').click()
|
||||
await expect(page.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 26 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 15 out of 26 students')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Studytrack MAST-MSM')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Population statistics is usable on general level', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await expect(page.getByTestId('filtered-students')).toBeVisible()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 27 out of 30 students')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Excludes exchange students')).toBeVisible()
|
||||
await expect(page.getByText('Excludes students with non-degree study right')).toBeVisible()
|
||||
await expect(page.getByText('Excludes students who have transferred out of this programme')).toBeVisible()
|
||||
await expect(page.getByTestId('filtered-students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Advanced settings work', async ({ page }) => {
|
||||
await page.goto(pathToMathMSc2020)
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 26 students)')).toBeVisible()
|
||||
await expect(page).toHaveURL(/.*semesters=FALL&semesters=SPRING.*/)
|
||||
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
|
||||
// only spring
|
||||
await page.getByTestId('toggle-fall').click()
|
||||
await page.getByText('Fetch class').click()
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 17 students)')).toBeVisible()
|
||||
await expect(page).not.toHaveURL(/.*semesters=FALL.*/)
|
||||
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
await page.getByTestId('toggle-fall').click()
|
||||
await page.getByTestId('toggle-spring').click()
|
||||
await page.getByText('Fetch class').click()
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 9 students)')).toBeVisible()
|
||||
await expect(page).not.toHaveURL(/.*semesters=SPRING.*/)
|
||||
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
await page.getByTestId('toggle-spring').click()
|
||||
await page.getByText('Fetch class').click()
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 26 students)')).toBeVisible()
|
||||
await expect(page).toHaveURL(/.*semesters=FALL&semesters=SPRING.*/)
|
||||
})
|
||||
|
||||
test.describe('Credit statistics', () => {
|
||||
test("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
|
||||
await expect(page.getByText('Credit statistics')).toBeVisible()
|
||||
await page.getByText('Credit statistics').click()
|
||||
|
||||
await expect(page.getByTestId('credits-gained-tab')).toBeVisible()
|
||||
await page.getByTestId('credits-gained-tab').click()
|
||||
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector: string, numbersOfStudents: number[]) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `credits-gained-table-${selector}`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('credits-gained-table-All students of the class')).toBeVisible()
|
||||
|
||||
await expect(page.getByTestId('credits-gained-table-All students of the class').locator('th')).toHaveCount(4)
|
||||
await expect(page.getByTestId('credits-gained-table-All students of the class').locator('th')).toContainText([
|
||||
'',
|
||||
'Credits gained between 01.08.2020 and ', // Current date
|
||||
`Number of students` + `(n = ${totalStudents})`,
|
||||
'Percentage of population',
|
||||
])
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
].map(category =>
|
||||
Promise.all(
|
||||
category.map(async ({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0 credits'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
const bodyLocator = page.getByTestId(selector).getByTestId('credits-gained-table-body')
|
||||
await expect(bodyLocator).toBeVisible()
|
||||
const row = bodyLocator.locator('tr').nth(index)
|
||||
await expect(row.locator('td')).toHaveCount(4)
|
||||
await expect(row.locator('td')).toHaveText(['' /* Icon */, value, students.toString(), percentage])
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const numberOfStudentCounts = await page
|
||||
.getByTestId('credits-gained-table-All students of the class')
|
||||
.getByTestId('credits-gained-table-body')
|
||||
.locator('td:nth-child(3)')
|
||||
.allTextContents()
|
||||
const sum = numberOfStudentCounts.reduce((acc, t) => acc + parseInt(t, 10), 0)
|
||||
expect(sum).toBe(totalStudents)
|
||||
})
|
||||
|
||||
test("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Credit statistics').click()
|
||||
|
||||
await page.getByTestId('credit-statistics-tab').click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
categories.map(async ({ selector, data, size }) => {
|
||||
const tableLocator = page.getByTestId(`statistics-table-${selector}`)
|
||||
await expect(tableLocator).toBeVisible()
|
||||
await expect(tableLocator.getByRole('heading', { level: 5, name: selector })).toBeVisible()
|
||||
|
||||
await expect(tableLocator.getByTestId('credit-stats-population-size')).toContainText(`n = ${size}`)
|
||||
|
||||
await Promise.all(
|
||||
rows.map((_, index) => {
|
||||
const row = tableLocator.locator('table tbody tr').nth(index)
|
||||
return expect(row.locator('td')).toHaveText([rows[index], data[index]])
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Courses of class', () => {
|
||||
test('Is displayed and link to individual course stats page works and has substitutions enabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Courses of class').click()
|
||||
await page.getByTestId('toggle-group-module-MAT110').click()
|
||||
|
||||
await page.getByRole('row', { name: 'MAT11001' }).getByRole('link').click()
|
||||
await expect(page).toHaveURL(/.*coursestatistics.*/)
|
||||
await expect(page.getByText('MAT11001', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('AYMAT11001')).toBeVisible()
|
||||
await expect(page.getByText('57033', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('A57033')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Curriculum selection works', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Courses of class').click()
|
||||
|
||||
await expect(page.getByTestId('curriculum-picker')).toContainText('2020–2023')
|
||||
await expect(page.getByTestId('toggle-group-module-MAT-tyo')).toBeVisible()
|
||||
|
||||
await page.getByTestId('curriculum-picker').click()
|
||||
await page.getByText('2023–2026').click()
|
||||
await expect(page.getByTestId('toggle-group-module-MAT-tyo')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Courses data is changed when curriculum is changed', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Courses of class').click()
|
||||
|
||||
const curriculumPicker = page.getByTestId('curriculum-picker')
|
||||
await expect(curriculumPicker.getByText('2020–2023')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Students (27)')).toBeVisible()
|
||||
|
||||
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
|
||||
await page.getByTestId('toggle-group-module-DIGI-k').click()
|
||||
|
||||
await expect(page.getByText('DIGI-100')).toBeVisible()
|
||||
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
|
||||
|
||||
await curriculumPicker.click()
|
||||
await page.getByText('2023–2026').click()
|
||||
|
||||
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
|
||||
await expect(page.getByText('DIGI-100')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Courses data is changed when filtered students change', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
const filterCard = page.getByTestId('graduatedFromProgrammeFilter-filter-card')
|
||||
await filterCard.getByTestId('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
await page.getByRole('radio', { name: 'Graduated', exact: true }).click()
|
||||
await expect(page.getByRole('radio', { name: 'Graduated', exact: true })).toBeChecked()
|
||||
await expect(page.getByText('Students (16)')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Students', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
const studentsHeading = page.getByText('Students (27)')
|
||||
const parentHasActive = await studentsHeading.evaluate(el => el.parentElement?.classList.contains('active'))
|
||||
if (!parentHasActive) await page.getByText('Students (27)').click()
|
||||
})
|
||||
|
||||
test("'General tab' is usable", async ({ page }) => {
|
||||
const general = page.getByTestId('ooditable-general')
|
||||
await expect(general).toBeVisible()
|
||||
|
||||
const row = general.locator('tbody').locator('tr').filter({ hasText: '489963' })
|
||||
await expect(row.locator('td')).toHaveCount(24)
|
||||
await expect(row.locator('td')).toHaveText(
|
||||
[
|
||||
'489963' + 'Sisu',
|
||||
'Graduated',
|
||||
241,
|
||||
0,
|
||||
169,
|
||||
0,
|
||||
0,
|
||||
2020,
|
||||
'2020-08-01',
|
||||
'2020-08-01',
|
||||
'2022-06-13',
|
||||
23,
|
||||
'',
|
||||
'Matematiikka',
|
||||
'Matematiikan ja tilastotieteen maisteriohjelma',
|
||||
'Matematiikan ja tilastotieteen maisteriohjelma',
|
||||
'',
|
||||
'Avoin väylä',
|
||||
'Male',
|
||||
'Suomi',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].map(n => n.toString())
|
||||
)
|
||||
})
|
||||
|
||||
test("'Courses tab' is usable", async ({ page }) => {
|
||||
await page.getByTestId('student-table-tabs').getByText('Courses').click()
|
||||
const courses = page.getByTestId('ooditable-courses')
|
||||
await expect(courses.getByText('MAT11001')).toBeVisible()
|
||||
await expect(courses.getByText('MAT11004')).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Modules tab' Displays correct modules based on the selected programme", async ({ page }) => {
|
||||
await page.getByTestId('student-table-tabs').getByText('Modules').click()
|
||||
const modules = page.getByTestId('ooditable-modules')
|
||||
|
||||
await expect(modules.getByText('MAT011')).toBeVisible()
|
||||
await expect(modules.getByText('MAT110')).toBeVisible()
|
||||
|
||||
await page.getByText('Courses of class').click()
|
||||
await page.getByTestId('curriculum-picker').click()
|
||||
await page.getByText('2023–2026').click()
|
||||
await page.getByText('Courses of class').click()
|
||||
|
||||
await expect(page.getByTestId('ooditable-modules').getByText('MAT011')).not.toBeVisible()
|
||||
await expect(page.getByTestId('ooditable-modules').getByText('MAT110')).toBeVisible()
|
||||
})
|
||||
|
||||
test("Empty 'tags' tab has a link to the page where tags can be created", async ({ page }) => {
|
||||
await page.getByTestId('student-table-tabs').getByText('Tags').click()
|
||||
await expect(page.getByText('No tags defined. You can define them')).toBeVisible()
|
||||
await page.getByRole('link', { name: 'here' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/.*study-programme\/KH50_001\?tab=4.*/)
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Create new tag')).toBeVisible()
|
||||
await expect(page.getByTestId('create-button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('When using admin', () => {
|
||||
test('Student list checking works as intended', async ({ page }) => {
|
||||
await init(page, pathToMathBSc2020, 'admin')
|
||||
const existing = '433237'
|
||||
const nonExisting = '550004'
|
||||
await expect(page.getByText('Students (27)')).toBeVisible()
|
||||
await page.getByText('Students (27)').click()
|
||||
|
||||
await expect(page.getByText(existing)).toBeVisible()
|
||||
await expect(page.getByText(nonExisting)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Check student numbers' }).click()
|
||||
await expect(page.getByText('Check for student numbers')).toBeVisible()
|
||||
await page.getByTestId('check-student-numbers').getByRole('textbox').fill(`${existing}\n${nonExisting}`)
|
||||
await page.getByRole('button', { name: 'Check students' }).click()
|
||||
|
||||
const results = page.locator('#checkstudentsresults')
|
||||
await results.getByTestId('found-title').click()
|
||||
await expect(results.getByTestId('found-data').getByText(existing)).toBeVisible()
|
||||
|
||||
await results.getByTestId('not-found-title').click()
|
||||
await expect(results.getByTestId('not-found-data').getByText(nonExisting)).toBeVisible()
|
||||
|
||||
await results.getByTestId('not-searched-title').click()
|
||||
await expect(results.getByTestId('not-searched-data').getByText('457144')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Close' }).click()
|
||||
await expect(page.getByText('Student numbers in list and in Sisu')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('When using IAM user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, pathToMathBSc2020, 'onlyiamrights')
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Population statistics is visible', async ({ page }) => {
|
||||
const card = page.getByTestId('PopulationQueryCard')
|
||||
await expect(card.getByText('Excludes exchange students')).toBeVisible()
|
||||
await expect(card.getByText('Excludes students with non-degree study right')).toBeVisible()
|
||||
await expect(card.getByText('Excludes students who have transferred out of this programme')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Only correct panels are visible', async ({ page }) => {
|
||||
const panelParent = page.getByTestId('panelview-parent')
|
||||
await expect(panelParent.getByText('Credit accumulation (for 27 students)')).toBeVisible()
|
||||
await expect(panelParent.getByText('Credit statistics')).toBeVisible()
|
||||
await expect(panelParent.getByText('Age distribution')).toBeVisible()
|
||||
await expect(panelParent.getByText('Courses of class')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ages cannot be ungrouped', async ({ page }) => {
|
||||
await expect(page.getByText('Age distribution')).toBeVisible()
|
||||
await page.getByText('Age distribution').click()
|
||||
await expect(page.getByText('Group ages')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Age filter is not visible', async ({ page }) => {
|
||||
await expect(page.locator("[data-cy='filtered-students']")).toBeVisible()
|
||||
await expect(page.locator("[data-cy='Age-filter-card']")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Students tab is not available', async ({ page }) => {
|
||||
await expect(page.getByText('Students (27)')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { userHeaders } from './support/commands'
|
||||
|
||||
type RouteRequirements = {
|
||||
userId?: string
|
||||
roles?: string[]
|
||||
requireProgrammeRights?: boolean
|
||||
programmeRights?: string[]
|
||||
iamGroups?: string[]
|
||||
}
|
||||
|
||||
type UserInfo = {
|
||||
userId?: string
|
||||
roles?: string[]
|
||||
programmeRights?: unknown[]
|
||||
iamGroups?: string[]
|
||||
}
|
||||
|
||||
const baseRoutes: [string, RouteRequirements][] = [
|
||||
['/', {}],
|
||||
['/changelog', {}],
|
||||
['/completedcoursessearch', {}],
|
||||
['/university', {}],
|
||||
['/feedback', {}],
|
||||
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
// TODO: This should probably be allowed for user with programmeRight, not only fullSisuAccess
|
||||
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/users', { roles: ['admin'] }],
|
||||
['/users/334', { roles: ['admin'] }],
|
||||
['/updater', { roles: ['admin'] }],
|
||||
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
|
||||
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
|
||||
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
|
||||
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/teachers', { roles: ['teachers'] }],
|
||||
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
|
||||
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
|
||||
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
|
||||
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
|
||||
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
|
||||
]
|
||||
|
||||
for (const user of userHeaders) {
|
||||
if (user.uid === 'admin') continue
|
||||
test.describe(`${user.displayname} has access only to views that were intended`, () => {
|
||||
let userInfo: UserInfo | undefined
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const response = await request.get('/api/login', { headers: user })
|
||||
userInfo = (await response.json())?.user
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
expect(userInfo).toBeDefined()
|
||||
|
||||
// Inject user headers into all API calls and fail if any of them results in a server error
|
||||
await page.route('**/api/**', async route => {
|
||||
const response = await route.fetch({
|
||||
headers: {
|
||||
...route.request().headers(),
|
||||
...user,
|
||||
},
|
||||
})
|
||||
expect(response.status(), `${route.request().method()} ${route.request().url()}`).toBeLessThan(500)
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
})
|
||||
|
||||
for (const [route, requirements] of baseRoutes) {
|
||||
test(`Checking access ${route}`, async ({ page }) => {
|
||||
// TODO: JAMI mock needed to make this test deterministic
|
||||
test.skip(user.uid === 'onlyiamrights' && route === '/coursestatistics')
|
||||
|
||||
await page.goto(route)
|
||||
|
||||
const { userId, roles = [], programmeRights = [], iamGroups = [] } = userInfo ?? {}
|
||||
const hasProgrammeRights = programmeRights.length > 0
|
||||
|
||||
const requiredUserId = !requirements.userId || requirements.userId === userId
|
||||
const requiredRoles = !requirements.roles || requirements.roles.some(role => roles.includes(role))
|
||||
const requiredIamGroups =
|
||||
!requirements.iamGroups || requirements.iamGroups.some(group => iamGroups.includes(group))
|
||||
let requiredRights = !requirements.requireProgrammeRights || hasProgrammeRights
|
||||
|
||||
if (requirements.programmeRights) {
|
||||
requiredRights =
|
||||
requirements.programmeRights.length === 0
|
||||
? hasProgrammeRights
|
||||
: roles.includes('fullSisuAccess') ||
|
||||
requirements.programmeRights.every(right => programmeRights.includes(right))
|
||||
}
|
||||
|
||||
// This is defined in frontend ProtectedRoute component.
|
||||
const specialNeedsRoutes = [
|
||||
'populations',
|
||||
'study-programme',
|
||||
'coursepopulation',
|
||||
'coursestatistics',
|
||||
'students',
|
||||
'custompopulation',
|
||||
]
|
||||
|
||||
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
|
||||
? requiredRoles || requiredRights
|
||||
: requiredRoles && requiredRights
|
||||
|
||||
const shouldHaveAccess = mankeli && requiredUserId && requiredIamGroups
|
||||
|
||||
if (shouldHaveAccess) {
|
||||
await expect(page.getByRole('heading', { name: 'Something broke' })).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).not.toBeVisible()
|
||||
} else {
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const student = {
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const searchFor = async (page: Page, value: string) => {
|
||||
await page.getByTestId('student-search').getByRole('textbox').fill(value)
|
||||
}
|
||||
|
||||
const openStudent = async (page: Page, studentNumber: string) => {
|
||||
await searchFor(page, studentNumber)
|
||||
await page.getByRole('cell', { name: studentNumber }).click()
|
||||
}
|
||||
|
||||
const expectNamesHidden = async (page: Page) => {
|
||||
await expect(page.getByText(student.firstNames)).toHaveCount(0)
|
||||
await expect(page.getByText(student.lastName)).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.describe('Students tests', () => {
|
||||
test.describe('When using basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/students')
|
||||
await expect(page).toHaveURL(/\/students/)
|
||||
await expect(page.getByText('Show student names')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Students search form is usable', async ({ page }) => {
|
||||
await searchFor(page, student.lastName)
|
||||
await expect(page.getByText('Student number')).toBeVisible()
|
||||
await expect(page.getByText('Last name')).toHaveCount(0)
|
||||
await expect(page.getByText('First names')).toHaveCount(0)
|
||||
await expect(page.getByText('Started')).toBeVisible()
|
||||
await expect(page.getByText('Credits')).toBeVisible()
|
||||
await expect(page.getByText('Active study rights')).toBeVisible()
|
||||
await expectNamesHidden(page)
|
||||
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expect(page.getByText(student.firstNames)).toBeVisible()
|
||||
await expect(page.getByText('Last name')).toBeVisible()
|
||||
await expect(page.getByText('First names')).toBeVisible()
|
||||
await expect(page.getByText(student.lastName)).toBeVisible()
|
||||
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expectNamesHidden(page)
|
||||
})
|
||||
|
||||
test('Search term must be at least 4 characters long', async ({ page }) => {
|
||||
await searchFor(page, student.lastName.slice(0, 3))
|
||||
await expect(page.getByText('Search term is not accurate enough')).toBeVisible()
|
||||
await page.getByTestId('student-search').getByRole('textbox').pressSequentially(student.lastName.slice(3)) // Appends to input
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can search with student number too', async ({ page }) => {
|
||||
await searchFor(page, student.studentNumber)
|
||||
await expect(page.getByText(student.studentNumber)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can get student specific page by clicking student', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')).toBeVisible()
|
||||
await expectNamesHidden(page)
|
||||
await expect(page.getByText(student.email, { exact: true })).toHaveCount(0)
|
||||
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expect(page.getByText(student.lastName)).toBeVisible()
|
||||
await expect(page.getByText(student.firstNames)).toBeVisible()
|
||||
await expect(page.getByText(student.email)).toBeVisible()
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expectNamesHidden(page)
|
||||
await expect(page.getByText(student.email)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("'Update student' button is not shown", async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByTestId('student-info-card').getByRole('button', { name: 'Update student' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can get back to search menu', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await page.goBack()
|
||||
await expect(page.getByText('Student number')).toHaveCount(0)
|
||||
await expect(page.getByText('Credits')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can jump to course', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
const courseRow = page.locator('tr').filter({ hasText: 'Tilastollinen päättely I (MAT12004)' })
|
||||
await courseRow.locator('a').click()
|
||||
await expect(page).toHaveURL(/\/coursestatistics/)
|
||||
await expect(page.getByText('MAT12004', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('AYMAT12004')).toBeVisible()
|
||||
await expect(page.getByText('57046', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Has correct Sisu link', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByTestId('sisu-link')).toHaveAttribute(
|
||||
'href',
|
||||
`https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`
|
||||
)
|
||||
})
|
||||
|
||||
test('Semester enrollments can be toggled', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
const card = page.getByTestId('student-info-card')
|
||||
await card.getByText('Enrollments').click()
|
||||
await expect(card.locator('table tbody tr')).toHaveCount(3)
|
||||
await expect(card.locator('table tbody tr td:first-child')).toHaveText(programmes)
|
||||
})
|
||||
|
||||
test("Searching with bad name doesn't yield results", async ({ page }) => {
|
||||
await searchFor(page, 'SWAG LITTINEN')
|
||||
await expect(page.getByText('Student number')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("Searching with bad student number doesn't yield results", async ({ page }) => {
|
||||
await searchFor(page, '01114')
|
||||
await expect(page.getByText('Student number')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can jump to population page', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await page
|
||||
.getByTestId('study-rights-section')
|
||||
.getByText('Matemaattisten tieteiden kandiohjelma')
|
||||
.getByRole('link')
|
||||
.click()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Grade graph works in all three different modes', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await page.getByRole('tab', { name: 'Grade graph' }).click()
|
||||
await page.getByRole('button', { name: 'Show group mean' }).click()
|
||||
|
||||
const groupSize = page.getByTestId('group-size-input')
|
||||
await expect(groupSize.getByLabel('Group size')).toBeVisible()
|
||||
await expect(groupSize.locator('input')).toHaveValue('5')
|
||||
|
||||
await groupSize.locator('input').fill('10')
|
||||
await expect(groupSize.locator('input')).toHaveValue('10')
|
||||
await page.getByRole('button', { name: 'Show semester mean' }).click()
|
||||
await expect(groupSize.getByLabel('Group size')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Bachelor Honours section', () => {
|
||||
test('Shows qualified student details', async ({ page }) => {
|
||||
await page.goto('/students/495976')
|
||||
await expect(page.getByRole('heading', { name: 'Bachelor Honours' })).toBeVisible()
|
||||
await expect(page.getByTestId('honours-chip-qualified')).toContainText('Qualified for Honours')
|
||||
await page.getByText('Study modules').click()
|
||||
|
||||
const rows = page.getByTestId('main-modules').locator('tbody tr')
|
||||
await expect(rows).toHaveCount(3)
|
||||
await expect(
|
||||
rows.filter({ hasText: 'Matemaattisten tieteiden kandiohjelma (KH50_001)' }).locator('td')
|
||||
).toHaveText(['07.05.2020', 'Matemaattisten tieteiden kandiohjelma (KH50_001)', 'Hyv.'])
|
||||
await expect(rows.filter({ hasText: 'Matematiikka, perusopinnot (MAT110)' }).locator('td')).toHaveText([
|
||||
'08.05.2018',
|
||||
'Matematiikka, perusopinnot (MAT110)',
|
||||
'4',
|
||||
])
|
||||
await expect(rows.filter({ hasText: 'Matematiikka, aineopinnot (MAT210)' }).locator('td')).toHaveText([
|
||||
'25.02.2020',
|
||||
'Matematiikka, aineopinnot (MAT210)',
|
||||
'4',
|
||||
])
|
||||
})
|
||||
|
||||
test('Shows Did not graduate in time', async ({ page }) => {
|
||||
await page.goto('/students/540355')
|
||||
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
|
||||
await expect(page.getByTestId('honours-chip-error')).toHaveText('Did not graduate in time')
|
||||
})
|
||||
|
||||
test('Shows Module grades too low', async ({ page }) => {
|
||||
await page.goto('/students/547934')
|
||||
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
|
||||
await expect(page.getByTestId('honours-chip-error')).toHaveText('Module grades too low')
|
||||
})
|
||||
|
||||
test('Shows Might need further inspection', async ({ page }) => {
|
||||
await page.goto('/students/478837')
|
||||
await expect(page.getByTestId('honours-chip-inspection')).toHaveText('Might need further inspection')
|
||||
})
|
||||
|
||||
test('Shows Has not graduated', async ({ page }) => {
|
||||
await page.goto(`/students/${student.studentNumber}`)
|
||||
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
|
||||
await expect(page.getByTestId('honours-chip-error')).toHaveText('Has not graduated')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('When using admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/students', 'admin')
|
||||
})
|
||||
|
||||
test('Does not crash if student has no study rights or courses', async ({ page }) => {
|
||||
await openStudent(page, '450730')
|
||||
await expect(page.getByText('Credits: 0')).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Update student' button is shown", async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByTestId('student-info-card').getByRole('button', { name: 'Update student' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bachelor Honours section is not shown outside Faculty of Science', async ({ page }) => {
|
||||
await page.goto('/students/453146')
|
||||
await expect(page.getByRole('heading', { name: 'Bachelor Honours' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Study plan courses are highlighted with a blue background', async ({ page }) => {
|
||||
await page.goto('/students/550789')
|
||||
const studyPlan = page.getByTestId('KH40_005-0')
|
||||
await expect(studyPlan).toContainText('Kulttuurien tutkimuksen kandiohjelma')
|
||||
await studyPlan.locator('td').filter({ hasText: 'Kulttuurien tutkimuksen kandiohjelma' }).click()
|
||||
await expect(page.locator('table tbody tr').filter({ hasText: 'Kandidaatintutkielma (KUKA-LIS222)' })).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(232, 244, 255)'
|
||||
)
|
||||
})
|
||||
|
||||
test('Completed credits are displayed for a matching study plan', async ({ page }) => {
|
||||
await page.goto('/students/550789')
|
||||
const studyPlan = page.getByTestId('KH40_005-0')
|
||||
await expect(studyPlan).toContainText('Kulttuurien tutkimuksen kandiohjelma')
|
||||
await expect(studyPlan).toContainText('185 cr')
|
||||
})
|
||||
|
||||
test('Completion percentage is displayed for an unfinished matching study plan', async ({ page }) => {
|
||||
await page.goto('/students/458723')
|
||||
const studyPlan = page.getByTestId('KH20_001-0')
|
||||
await expect(studyPlan).toContainText('Oikeusnotaarin koulutusohjelma')
|
||||
await expect(studyPlan).toContainText('69% (125 cr)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, Page, test } from '@playwright/test'
|
||||
import { init, type UserId } from './support/commands'
|
||||
|
||||
const studyGuidanceGroups = [
|
||||
{
|
||||
id: 'sgg-cypress-1',
|
||||
name: { fi: 'MAT kandit 2020' },
|
||||
members: [{ personStudentNumber: '433237' }, { personStudentNumber: '457144' }, { personStudentNumber: '458090' }],
|
||||
tags: { studyProgramme: 'KH50_001', year: '2020' },
|
||||
},
|
||||
{
|
||||
id: 'sgg-cypress-2',
|
||||
name: { fi: 'Oma ohjausryhmä' },
|
||||
members: [{ personStudentNumber: '474270' }, { personStudentNumber: '479440' }],
|
||||
tags: { studyProgramme: null, year: null },
|
||||
},
|
||||
]
|
||||
|
||||
const initWithGroups = async (page: Page, path: string, userId: UserId = 'onlystudyguidancegroups') => {
|
||||
await init(page, path, userId)
|
||||
await page.route('**/api/studyguidancegroups', async route => await route.fulfill({ json: studyGuidanceGroups }))
|
||||
await page.reload()
|
||||
}
|
||||
|
||||
test.describe('Study guidance group tests', () => {
|
||||
test.describe('Study guidance group overview page', () => {
|
||||
test('without study guidance groups shows the correct notification', async ({ page }) => {
|
||||
await init(page, '/studyguidancegroups', 'admin')
|
||||
await expect(page.getByRole('heading', { name: 'Study guidance groups' })).toBeVisible()
|
||||
await expect(page.getByText('You do not have access to any study guidance groups.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('without correct rights shows the correct notification', async ({ page }) => {
|
||||
await init(page, '/studyguidancegroups', 'basic')
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
"You don't currently have permission to view this page. If you believe this is a mistake, please contact oodikone@helsinki.fi."
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('with study guidance groups', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups')
|
||||
await expect(page.getByRole('heading', { name: 'Study guidance groups' })).toBeVisible()
|
||||
await expect(page.getByText('You do not have access to any study guidance groups')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the correct data', async ({ page }) => {
|
||||
const row = page.getByTestId('study-guidance-group-overview-data-table').locator('tr')
|
||||
|
||||
// NOTE: Only MAT kandit 2020 can be edited
|
||||
await expect(row.filter({ hasText: 'MAT kandit 2020' }).locator('td')).toHaveText([
|
||||
'MAT kandit 2020',
|
||||
'3',
|
||||
'Matemaattisten tieteiden kandiohjelma' + 'Edit',
|
||||
'2020 - 2021' + 'Edit',
|
||||
])
|
||||
await expect(row.filter({ hasText: 'Oma ohjausryhmä' }).locator('td')).toHaveText([
|
||||
'Oma ohjausryhmä',
|
||||
'2',
|
||||
'Add degree programme',
|
||||
'Add starting year',
|
||||
])
|
||||
})
|
||||
|
||||
test('has working links to single study guidance groups', async ({ page }) => {
|
||||
await page.getByTestId('study-guidance-group-link-sgg-cypress-1').click()
|
||||
await expect(page).toHaveURL('/studyguidancegroups/sgg-cypress-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Page for single study guidance group', () => {
|
||||
test.describe('without associated degree programme and year', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-2')
|
||||
})
|
||||
|
||||
test('shows the correct panes', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Oma ohjausryhmä' })).toBeVisible()
|
||||
await expect(page.getByTestId('Credit accumulation (for 2 students)')).toBeVisible()
|
||||
await expect(page.getByTestId('Age distribution')).toBeVisible()
|
||||
await expect(page.getByTestId('Courses of population')).toBeVisible()
|
||||
await expect(page.getByText('Students (2)')).toBeVisible()
|
||||
await expect(page.getByTestId('Credit statistics')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('students table has the correct tabs', async ({ page }) => {
|
||||
await page.getByText('Students (2)').click()
|
||||
await expect(page.getByTestId('student-table-tabs').getByRole('tab', { name: 'General' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('general tab of the students table has the correct columns', async ({ page }) => {
|
||||
const columns = [
|
||||
'Student number',
|
||||
'Credits',
|
||||
'Start date',
|
||||
'Degree programmes',
|
||||
'TVEX',
|
||||
'Tags',
|
||||
'Total',
|
||||
'Since 1.1.1970',
|
||||
'University',
|
||||
]
|
||||
await page.getByText('Students (2)').click()
|
||||
await expect(page.getByTestId('ooditable-general').locator('table thead th')).toHaveText(columns)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with associated degree programme and year', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-1')
|
||||
})
|
||||
|
||||
test('shows the correct labels', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'MAT kandit 2020' })).toBeVisible()
|
||||
await expect(page.getByLabel('Associated degree programme')).toHaveText('Matemaattisten tieteiden kandiohjelma')
|
||||
await expect(page.getByLabel('Associated starting academic')).toHaveText('2020 - 2021')
|
||||
})
|
||||
|
||||
test('shows the correct panes', async ({ page }) => {
|
||||
await expect(page.getByTestId('Credit accumulation (for 3 students)')).toBeVisible()
|
||||
await expect(page.getByTestId('Credit statistics')).toBeVisible()
|
||||
await expect(page.getByTestId('Age distribution')).toBeVisible()
|
||||
await expect(page.getByTestId('Courses of population')).toBeVisible()
|
||||
await expect(page.getByText('Students (3)')).toBeVisible()
|
||||
})
|
||||
|
||||
test("clicking the 'Show credits starting from the associated academic year' filter", async ({ page }) => {
|
||||
const filter = page.getByTestId('ParticipationDate-filter-card')
|
||||
await expect(filter).toHaveAttribute('data-active', 'false')
|
||||
await page.getByTestId('Credit accumulation (for 3 students)').click()
|
||||
await page.getByText('Show credits starting from the associated academic year').click()
|
||||
|
||||
await expect(filter).toHaveAttribute('data-active', 'true')
|
||||
await expect(page.locator('.date-picker').first().locator('input')).toHaveValue('01.08.2020')
|
||||
await expect(page.getByText('Reset All Filters')).toBeVisible()
|
||||
await page.getByText('Show credits starting from the associated academic year').click()
|
||||
await expect(filter).toHaveAttribute('data-active', 'false')
|
||||
})
|
||||
|
||||
test('students table has the correct tabs', async ({ page }) => {
|
||||
await page.getByText('Students (3)').click()
|
||||
await expect(page.getByTestId('student-table-tabs').getByRole('tab')).toHaveText([
|
||||
'General',
|
||||
'Courses',
|
||||
'Modules',
|
||||
'Progress',
|
||||
])
|
||||
})
|
||||
|
||||
test('general tab of the students table has the correct columns', async ({ page }) => {
|
||||
const columns = [
|
||||
'Student number',
|
||||
'Status',
|
||||
'Credits',
|
||||
'Prior to bachelor',
|
||||
'Start date',
|
||||
'Graduation date',
|
||||
'Semesters present',
|
||||
'Study track',
|
||||
'Other programmes',
|
||||
'Transferred From',
|
||||
'Admission Type',
|
||||
'Gender',
|
||||
'Citizenships',
|
||||
'Curriculum period',
|
||||
'Latest attainment date',
|
||||
'TVEX',
|
||||
'Tags',
|
||||
'Total',
|
||||
'In study plan',
|
||||
'Since start in programme',
|
||||
'Credits',
|
||||
'Courses',
|
||||
'University',
|
||||
'Study right',
|
||||
'Programme',
|
||||
]
|
||||
await page.getByText('Students (3)').click()
|
||||
await expect(page.getByTestId('ooditable-general').locator('table thead th')).toHaveText(columns)
|
||||
})
|
||||
})
|
||||
|
||||
test('has a working back button', async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-1')
|
||||
await page.getByRole('button', { name: 'Back to groups' }).click()
|
||||
await expect(page).toHaveURL('/studyguidancegroups')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
test.describe('Teachers page tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// login as admin = has teacher rights
|
||||
await init(page, '/teachers', 'admin')
|
||||
expect(page.url()).toContain('/teachers')
|
||||
})
|
||||
|
||||
const teacher1 = 'Luokkanen Liisa Viljami'
|
||||
const teacher2 = 'Perälä Juhani Susanna'
|
||||
|
||||
test('Check Statistics', async ({ page }) => {
|
||||
await page.getByTestId('semester-start').click()
|
||||
await page.getByText('Syksy 2020').click()
|
||||
|
||||
await page.getByTestId('course-providers').click()
|
||||
await page.getByText('Matemaattisten tieteiden kandiohjelma').click()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.getByTestId('search-statistics').click()
|
||||
await expect(page.getByRole('columnheader')).toHaveCount(4)
|
||||
await expect(page.getByText('Export to Excel')).toBeVisible()
|
||||
|
||||
const teacher1Row = page.locator('tr').filter({ hasText: teacher1 })
|
||||
await expect(teacher1Row.getByText(teacher1)).toBeVisible()
|
||||
await expect(teacher1Row.getByText('235')).toBeVisible()
|
||||
await expect(teacher1Row.getByText('97.40%')).toBeVisible()
|
||||
|
||||
const teacher2Row = page.locator('tr').filter({ hasText: teacher2 })
|
||||
await expect(teacher2Row.getByText(teacher2)).toBeVisible()
|
||||
await expect(teacher2Row.getByText('395')).toBeVisible()
|
||||
await expect(teacher2Row.getByText('98.78%')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Teacher search works', async ({ page }) => {
|
||||
await page.getByTestId('Search').click()
|
||||
await page
|
||||
.getByTestId('teacher-search')
|
||||
.getByPlaceholder('Search by entering a name or an id')
|
||||
.fill(teacher1.split(' ')[0])
|
||||
await expect(page.getByText(teacher1)).toBeVisible()
|
||||
await expect(page.locator('tbody').locator('tr')).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('Can check teacher page', async ({ page, context }) => {
|
||||
// Re-init with context so the tab opened from the teacher link gets mocked headers too.
|
||||
await init(page, '/teachers', 'admin', context)
|
||||
await page.getByTestId('Search').click()
|
||||
await page.getByTestId('teacher-search').getByPlaceholder('Search by entering a name or an id').fill(teacher2)
|
||||
|
||||
await expect(page.getByText(teacher2)).toBeVisible()
|
||||
expect(await page.getByText(teacher2).getAttribute('href')).toBe('/teachers/hy-hlo-49026530')
|
||||
|
||||
// Change to other tab
|
||||
const pagePromise = context.waitForEvent('page')
|
||||
await page.getByRole('link', { name: teacher2 }).click()
|
||||
const newPage = await pagePromise
|
||||
|
||||
await newPage.waitForURL('**/teachers/hy-hlo-49026530')
|
||||
await expect(newPage.getByRole('heading', { name: teacher2 })).toBeVisible()
|
||||
await newPage.getByText('Syksy 2023').click()
|
||||
await newPage.getByText('Kevät 2019').click()
|
||||
|
||||
await expect(newPage.getByText('MAT12004')).toBeVisible()
|
||||
await expect(newPage.getByText('MAT22002')).toBeVisible()
|
||||
await expect(newPage.getByText('MAT22003')).toBeVisible()
|
||||
|
||||
const courseRow = newPage.locator('tr').filter({ hasText: 'MAT12004' })
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
await expect(courseRow.locator('td')).toHaveText(rowContent)
|
||||
})
|
||||
|
||||
test('Check leaderboad works', async ({ page }) => {
|
||||
await page.getByTestId('Leaderboard').click()
|
||||
await page.getByTestId('academic-year').click()
|
||||
await page.getByText('2020-2021').click()
|
||||
|
||||
await expect(page.getByRole('columnheader')).toHaveCount(4)
|
||||
await expect(page.getByText('Export to Excel')).toBeVisible()
|
||||
await expect(page.getByText(teacher1)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
|
||||
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
|
||||
|
||||
const checkProgressBarCharts = async (page: Parameters<typeof init>[0]) => {
|
||||
for (const level of progressLevels) {
|
||||
await expect(page.getByTestId(`faculty-${level}-progress-bar-chart-section`)).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
}
|
||||
|
||||
const checkProgressTables = async (page: Parameters<typeof init>[0]) => {
|
||||
for (const level of progressLevels) {
|
||||
await expect(page.getByTestId(`${level}-faculty-progress-table`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
const checkGraduationCharts = async (page: Parameters<typeof init>[0], mode: string) => {
|
||||
for (const level of graduationTimesLevels) {
|
||||
const chart = page.getByTestId(`${level}-${mode}-bar-chart`)
|
||||
await expect(chart).toBeVisible()
|
||||
await expect(chart.getByText('Loading content')).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('University view', () => {
|
||||
test.slow()
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/university')
|
||||
await expect(page.getByRole('heading', { name: 'University', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Faculty progress tab', () => {
|
||||
test('contains all the correct progress bar charts', async ({ page }) => {
|
||||
await checkProgressBarCharts(page)
|
||||
})
|
||||
|
||||
test('contains all the correct progress tables', async ({ page }) => {
|
||||
await checkProgressTables(page)
|
||||
})
|
||||
|
||||
test("'All study rights / Special study rights excluded' toggle works", async ({ page }) => {
|
||||
await page.getByTestId('study-right-toggle').click()
|
||||
await checkProgressBarCharts(page)
|
||||
await checkProgressTables(page)
|
||||
})
|
||||
|
||||
test('info boxes contain correct information', async ({ page }) => {
|
||||
await page.getByTestId('faculty-progress-info-box-button').click()
|
||||
await expect(page.getByTestId('faculty-progress-info-box-content')).toContainText('Kuvaa tiedekuntaan kuuluvien')
|
||||
|
||||
await page.getByTestId('faculty-bachelor-masters-progress-info-box-button').click()
|
||||
await expect(page.getByTestId('faculty-bachelor-masters-progress-info-box-content')).toContainText(
|
||||
'The starting year is the'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Faculty graduations tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
test.describe('Different modes work', () => {
|
||||
test('Breakdown', async ({ page }) => {
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-breakdown').click()
|
||||
await checkGraduationCharts(page, 'breakdown')
|
||||
})
|
||||
|
||||
test('Median', async ({ page }) => {
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-median').click()
|
||||
await checkGraduationCharts(page, 'median')
|
||||
})
|
||||
|
||||
test('Average', async ({ page }) => {
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-average').click()
|
||||
await checkGraduationCharts(page, 'average')
|
||||
})
|
||||
})
|
||||
|
||||
test('info boxes contain correct information', async ({ page }) => {
|
||||
await page.getByTestId('average-graduation-times-info-box-button').hover()
|
||||
await expect(page.getByTestId('average-graduation-times-info-box-content')).toContainText(
|
||||
'Opiskelijoiden keskimääräiset valmistumisajat'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const visibleLinks = {
|
||||
norights: ['University', 'Faculties', 'Special populations', 'Feedback'],
|
||||
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Feedback'],
|
||||
basic: [] as string[],
|
||||
admin: [] as string[],
|
||||
}
|
||||
|
||||
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = async (page: Page, links: string[]) => {
|
||||
const navBar = page.getByTestId('nav-bar')
|
||||
await Promise.all(links.map(link => expect(navBar.getByText(link)).toBeVisible()))
|
||||
}
|
||||
|
||||
const userButtonWorks = async (page: Page, username: string, mocking = false) => {
|
||||
await page.getByTestId('nav-bar-user-button').click()
|
||||
|
||||
await expect(page.getByText(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)).toBeVisible()
|
||||
await expect(page.getByText('Language')).toBeVisible()
|
||||
await expect(page.getByText('suomi')).toBeVisible()
|
||||
await expect(page.getByText('English')).toBeVisible()
|
||||
await expect(page.getByText('svenska')).toBeVisible()
|
||||
await expect(page.getByText(mocking ? 'Stop mocking' : 'Log out')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Users tests', () => {
|
||||
test.describe('Using as user with just grp-oodikone-user, no other rights', () => {
|
||||
test('shows correct tabs', async ({ page }) => {
|
||||
await init(page, '', 'norights')
|
||||
await containsLinks(page, visibleLinks.norights)
|
||||
await userButtonWorks(page, 'norights')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Using as coursestatistics user', () => {
|
||||
test('shows correct tabs', async ({ page }) => {
|
||||
await init(page, '', 'onlycoursestatistics')
|
||||
await containsLinks(page, visibleLinks.onlycoursestatistics)
|
||||
await userButtonWorks(page, 'onlycoursestatistics')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Using as basic user', () => {
|
||||
test('shows correct tabs', async ({ page }) => {
|
||||
await init(page, '', 'basic')
|
||||
await containsLinks(page, visibleLinks.basic)
|
||||
await userButtonWorks(page, 'basic')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Using as admin', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/users', 'admin')
|
||||
})
|
||||
|
||||
test('should see more stuff than others', async ({ page }) => {
|
||||
await containsLinks(page, visibleLinks.admin)
|
||||
await userButtonWorks(page, 'admin')
|
||||
})
|
||||
|
||||
test.describe('can mock as other users', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('user-page-button-basic').click()
|
||||
|
||||
await expect(page.getByText('Basic User')).toBeVisible()
|
||||
await expect(page.getByText('IAM Groups')).toBeVisible()
|
||||
await expect(page.getByText('Roles')).toBeVisible()
|
||||
await expect(page.getByText('Degree programme rights')).toBeVisible()
|
||||
|
||||
await page.getByTestId('mock-button').click()
|
||||
})
|
||||
|
||||
test('user button shows mocked user', async ({ page }) => {
|
||||
await userButtonWorks(page, 'basic', true)
|
||||
})
|
||||
|
||||
test("only the mocked user's programmes are visible", async ({ page }) => {
|
||||
await page.route('**/api/populationstatistics/studyprogrammes', async route => {
|
||||
const response = await route.fetch()
|
||||
expect(response.status()).toBeLessThanOrEqual(304)
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
|
||||
await init(page, '/populations', 'basic')
|
||||
await expect(page.getByRole('heading', { name: 'Degree programme' })).toBeVisible()
|
||||
|
||||
const populationProgrammeSelector = page.getByTestId('population-programme-selector')
|
||||
await expect(populationProgrammeSelector.getByPlaceholder('Select degree programme')).toBeVisible()
|
||||
|
||||
const programmeSelectorParent = page.getByTestId('population-programme-selector-parent')
|
||||
await programmeSelectorParent.click()
|
||||
await expect(programmeSelectorParent).toContainText('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// ! IMPORTANT: here we need to set keys to be all lowercase, since
|
||||
// ! we're replacing headers after they've left browser / frontend.
|
||||
|
||||
import { BrowserContext, expect, Page } from '@playwright/test'
|
||||
|
||||
// All of these users are available in anon user-db
|
||||
const adminUserHeaders = {
|
||||
uid: 'admin',
|
||||
displayname: 'Admin User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users;grp-toska',
|
||||
mail: 'grp-toska+mockadminuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-6666666',
|
||||
}
|
||||
|
||||
const basicUserHeaders = {
|
||||
uid: 'basic',
|
||||
displayname: 'Basic User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users',
|
||||
mail: 'grp-toska+mockbasicuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-1234567',
|
||||
}
|
||||
|
||||
const onlycoursestatisticsUserHeaders = {
|
||||
uid: 'onlycoursestatistics',
|
||||
displayname: 'Onlycoursestatistics User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-basic-users',
|
||||
mail: 'grp-toska+mockonlycoursestatisticsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-7654321',
|
||||
}
|
||||
|
||||
const norightsUserHeaders = {
|
||||
uid: 'norights',
|
||||
displayname: 'Norights User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users',
|
||||
mail: 'grp-toska+mocknorightuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-0000000',
|
||||
}
|
||||
|
||||
const onlyIamRightsUserHeaders = {
|
||||
uid: 'onlyiamrights',
|
||||
displayname: 'Only IAM rights user',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'hy-employees;hy-mltdk-mat-jory',
|
||||
mail: 'grp-toska+mockonlyiamrightsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-1111111',
|
||||
}
|
||||
|
||||
// Also has element detail of "KH50_005" for the study guidance groups to work
|
||||
const onlyStudyGuidanceGroupsUser = {
|
||||
uid: 'onlystudyguidancegroups',
|
||||
displayname: 'Study Guidance Groups User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users',
|
||||
mail: 'grp-toska+mockonlystudyguidancegroupsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-2222222',
|
||||
}
|
||||
|
||||
export const userHeaders = [
|
||||
adminUserHeaders,
|
||||
basicUserHeaders,
|
||||
onlycoursestatisticsUserHeaders,
|
||||
norightsUserHeaders,
|
||||
onlyIamRightsUserHeaders,
|
||||
onlyStudyGuidanceGroupsUser,
|
||||
]
|
||||
|
||||
export const testMockTime = '2026-03-06'
|
||||
|
||||
export type UserId =
|
||||
| 'admin'
|
||||
| 'basic'
|
||||
| 'onlycoursestatistics'
|
||||
| 'norights'
|
||||
| 'onlyiamrights'
|
||||
| 'onlystudyguidancegroups'
|
||||
|
||||
export const init = async (page: Page, path: string, userId: UserId = 'basic', context?: BrowserContext) => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
|
||||
// Prefer context-level routing when provided so new tabs inherit header injection.
|
||||
const routeTarget = context ?? page
|
||||
|
||||
await routeTarget.route('**/api/**', async route => {
|
||||
await route.continue({
|
||||
headers: {
|
||||
...route.request().headers(),
|
||||
...headersToUse,
|
||||
'x-test-now': testMockTime,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.clock.install({ time: testMockTime })
|
||||
await page.goto(path || '/')
|
||||
}
|
||||
|
||||
export const checkTableStats = async (page: Page, correctStats: (string | number)[][], tableName: string) => {
|
||||
const testId = tableName + '-data-table'
|
||||
await expect(page.getByTestId(testId).getByText('Loading content')).not.toBeVisible()
|
||||
|
||||
const table = page.getByTestId(testId).locator('tbody')
|
||||
await expect(table).toBeVisible()
|
||||
await expect(table.locator('tr > td')).toHaveText(correctStats.flatMap(stats => stats.map(value => value.toString())))
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
import globals from 'globals'
|
||||
import eslintPluginCypress from 'eslint-plugin-cypress'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import eslintPluginReact from 'eslint-plugin-react'
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import eslintPluginImportX from 'eslint-plugin-import-x'
|
||||
import { createNodeResolver } from 'eslint-plugin-import-x'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// Global ignores
|
||||
ignores: [
|
||||
'eslint.config.mjs',
|
||||
'**/build/**',
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/instrumented/**',
|
||||
'**/coverage/**',
|
||||
'nyc-config.cjs',
|
||||
'.lintstagedrc.mjs',
|
||||
'cypress.config.js',
|
||||
],
|
||||
},
|
||||
|
||||
// Global options (excl. e2e)
|
||||
{
|
||||
ignores: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
eslintPluginImportX.flatConfigs.recommended,
|
||||
eslintPluginImportX.flatConfigs.typescript,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.{js,cjs}'],
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'import-x': eslintPluginImportX,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
rules: {
|
||||
camelcase: 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
|
||||
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
|
||||
'id-denylist': ['error', 'err'],
|
||||
'no-alert': 'error',
|
||||
'no-await-in-loop': 'error',
|
||||
'no-console': 'error',
|
||||
'no-implied-eval': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-param-reassign': 'error',
|
||||
'no-promise-executor-return': 'error',
|
||||
'no-return-assign': 'error',
|
||||
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
|
||||
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
regex: '^@mui/[^/]+$',
|
||||
message:
|
||||
'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.',
|
||||
},
|
||||
{
|
||||
regex: '^src/.*',
|
||||
message: 'Root imports will not resolve in production build (instead silently crash).',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
|
||||
'import-x/no-commonjs': 'error',
|
||||
'import-x/no-default-export': 'error',
|
||||
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import-x/no-unused-modules': [
|
||||
'error',
|
||||
{
|
||||
missingExports: true,
|
||||
unusedExports: true,
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}'],
|
||||
},
|
||||
],
|
||||
'import-x/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
},
|
||||
],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/class-methods-use-this': 'error',
|
||||
// '@typescript-eslint/consistent-indexed-object-style': 'off', // TODO: keep as error/record (default)
|
||||
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/dot-notation': 'error',
|
||||
'@typescript-eslint/prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
|
||||
'@typescript-eslint/no-implied-eval': 'error',
|
||||
'@typescript-eslint/no-shadow': 'off', // TODO: enable?
|
||||
'@typescript-eslint/no-unsafe-argument': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-call': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-return': 'off', // TODO: enable
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
},
|
||||
settings: {
|
||||
'import-x/internal-regex': '^@oodikone/shared/',
|
||||
'import-x/resolver-next': [
|
||||
createTypeScriptImportResolver({
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
}),
|
||||
createNodeResolver({
|
||||
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['services/backend/src/routes/*.{ts,js}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/consistent-return': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Disable type-aware linting for javascript files
|
||||
{
|
||||
files: ['{updater,services}/**/*.{js,cjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
},
|
||||
|
||||
// Frontend camelCase and react
|
||||
{
|
||||
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
react: eslintPluginReact,
|
||||
},
|
||||
extends: [
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||
eslintPluginReactHooks.configs['recommended-latest'],
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
camelcase: 'warn',
|
||||
'react/prop-types': 'off', // Typescript but worse
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/no-unstable-nested-components': 'warn', // TODO: turn into error, reduces re-renders
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/jsx-tag-spacing': 'warn',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Cypress should only use rules provided by the plugin below
|
||||
{
|
||||
files: ['cypress/**/*.js'],
|
||||
extends: [eslintPluginCypress.configs.recommended],
|
||||
},
|
||||
|
||||
// Override for backend api tests
|
||||
{
|
||||
files: ['services/backend/tests/**/*.ts'],
|
||||
rules: {
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Overrides for CommonJS
|
||||
{
|
||||
files: [
|
||||
'services/backend/**/*.js',
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'updater/sis-updater-scheduler/**/*.js',
|
||||
],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'import-x/no-commonjs': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Migration files
|
||||
{
|
||||
files: ['updater/sis-updater-worker/src/db/migrations/*.cjs', 'services/backend/src/database/**/*.js'],
|
||||
rules: {
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: See if these can be disabled and or fix
|
||||
{
|
||||
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
|
||||
rules: {
|
||||
'import-x/no-default-export': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// Backend tests
|
||||
{
|
||||
files: ["services/backend/tests/**/*.ts", "services/backend/vitest.config.ts"],
|
||||
rules: {
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
}
|
||||
},
|
||||
|
||||
// Frontend tests
|
||||
{
|
||||
files: ['services/frontend/tests/**/*.{ts,tsx}', "services/frontend/playwright/**/*.{ts,tsx}", 'services/frontend/playwright-ct.config.ts'],
|
||||
rules: {
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'import-x/no-default-export': 'off',
|
||||
'react/jsx-filename-extension': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
|
||||
)
|
||||
Generated
+1328
-2364
File diff suppressed because it is too large
Load Diff
+14
-16
@@ -5,10 +5,14 @@
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cypress": "cypress",
|
||||
"cypress:run": "cypress run --browser firefox",
|
||||
"eslint": "eslint",
|
||||
"lint:all": "eslint services/ updater/",
|
||||
"playwright": "playwright test -c playwright.config.ts",
|
||||
"playwright:ui": "playwright test --ui -c playwright.config.ts",
|
||||
"playwright:headed": "playwright test --headed -c playwright.config.ts",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"lint:quiet": "oxlint --quiet",
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check",
|
||||
"stylelint": "stylelint",
|
||||
"prettier": "prettier",
|
||||
"cli": "./cli.sh",
|
||||
@@ -28,10 +32,8 @@
|
||||
"flushredis": "docker exec -it oodikone-redis redis-cli flushall",
|
||||
"prepare": "husky",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec --user 0 oodikone-frontend sh -lc \"npm i --prefix /opt/app-root/shared && npm i --prefix /opt/app-root/frontend && chown -R 1001:0 /opt/app-root/shared/node_modules /opt/app-root/frontend/node_modules\" && docker exec --user 0 oodikone-backend sh -lc \"npm i --prefix /opt/app-root/backend && chown -R 1001:0 /opt/app-root/backend/node_modules\" && docker exec --user 0 oodikone-updater-scheduler sh -lc \"npm i --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\" && docker exec --user 0 oodikone-updater-worker sh -lc \"npm i --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\"",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"ci:docker": "docker exec --user 0 oodikone-frontend sh -lc \"npm ci --prefix /opt/app-root/shared && npm ci --prefix /opt/app-root/frontend && chown -R 1001:0 /opt/app-root/shared/node_modules /opt/app-root/frontend/node_modules\" && docker exec --user 0 oodikone-backend sh -lc \"npm ci --prefix /opt/app-root/backend && chown -R 1001:0 /opt/app-root/backend/node_modules\" && docker exec --user 0 oodikone-updater-scheduler sh -lc \"npm ci --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\" && docker exec --user 0 oodikone-updater-worker sh -lc \"npm ci --prefix /opt/app-root/src && chown -R 1001:0 /opt/app-root/src/node_modules\"",
|
||||
"ci:docker": "docker exec oodikone-frontend sh -lc \"npm ci --prefix /opt/app-root/shared && npm ci\" && docker exec oodikone-backend npm ci && docker exec oodikone-updater-scheduler npm ci && docker exec oodikone-updater-worker npm ci",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
@@ -44,22 +46,18 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"cypress": "^15.13.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^6.2.3",
|
||||
"@playwright/test": "1.61.0",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"oxfmt": "0.58.0",
|
||||
"oxlint": "1.73.0",
|
||||
"oxlint-tsgolint": "0.25.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^17.6.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
"stylelint-config-standard": "^40.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// oxlint-disable-next-line no-default-export
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: ['**/*.spec.ts'],
|
||||
testIgnore: ['**/support/**', '**/plugins/**'],
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code.
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
|
||||
reporter: [['list', { printFailuresInline: true }]],
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.CI ? 'http://localhost:1337' : process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
trace: 'on-first-retry',
|
||||
testIdAttribute: 'data-cy',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -209,6 +209,47 @@ reset_single_database() {
|
||||
fi
|
||||
}
|
||||
|
||||
nuke_node_modules() {
|
||||
folders_to_set_up=(
|
||||
"$PROJECT_ROOT"
|
||||
"$PROJECT_ROOT/services/frontend"
|
||||
"$PROJECT_ROOT/services/backend"
|
||||
"$PROJECT_ROOT/services/shared"
|
||||
"$PROJECT_ROOT/updater/sis-updater-scheduler"
|
||||
"$PROJECT_ROOT/updater/sis-updater-worker"
|
||||
)
|
||||
|
||||
named_volumes=(
|
||||
"oodikone-shared-nodemod"
|
||||
"oodikone-frontend-nodemod"
|
||||
"oodikone-backend-nodemod"
|
||||
"oodikone-updater-worker-nodemod"
|
||||
"oodikone-updater-scheduler-nodemod"
|
||||
)
|
||||
|
||||
infomsg "Running docker compose down"
|
||||
"$PROJECT_ROOT"/run.sh both down --remove-orphans || infomsg "brr"
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
rm -rf node_modules || die "Couldn't remove node_modules in folder $folder"
|
||||
done
|
||||
|
||||
for volume in "${named_volumes[@]}"; do
|
||||
docker volume rm "$volume" || infomsg "Couldn't delete volume $volume (already gone?)"
|
||||
done
|
||||
|
||||
infomsg "Installing npm packages locally to root and each subfolder to enable linting and formatting (and LSP)"
|
||||
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
([[ -d node_modules ]] && die "$folder already has node_modules, exiting!") || npm ci
|
||||
done
|
||||
|
||||
cd "$PROJECT_ROOT" || die "Couldn't change directory to project root"
|
||||
|
||||
successmsg "Node_module nuke performed"
|
||||
}
|
||||
|
||||
set_up_oodikone_from_scratch() {
|
||||
draw_mopo
|
||||
folders_to_set_up=(
|
||||
|
||||
@@ -5,17 +5,16 @@ ENV TZ="Europe/Helsinki"
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
USER 0
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY --chown=1001:0 ./shared .
|
||||
RUN npm ci && chown -R 1001:0 /opt/app-root/shared
|
||||
|
||||
COPY ./shared .
|
||||
RUN npm ci
|
||||
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY --chown=1001:0 ./backend .
|
||||
RUN npm ci && chown -R 1001:0 /opt/app-root/backend
|
||||
|
||||
COPY ./backend .
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
USER 1001
|
||||
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
|
||||
|
||||
Generated
+602
-354
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^10.43.0",
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.18.1",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
@@ -22,16 +22,16 @@
|
||||
"express": "^5.2.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"morgan": "^1.11.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.20.0",
|
||||
"redis": "^4.7.1",
|
||||
"sequelize": "^6.37.8",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"umzug": "^3.8.3",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"winston": "^3.17.0",
|
||||
"winston-loki": "^6.1.3"
|
||||
"winston": "^3.19.0",
|
||||
"winston-loki": "^6.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
@@ -45,7 +45,7 @@
|
||||
"nodemon": "^3.1.11",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { updateBasicView, updateStudyTrackView } from './services/studyProgramme
|
||||
import { findAndSaveTeachers } from './services/teachers/top'
|
||||
import { deleteOutdatedUsers } from './services/userService'
|
||||
import logger from './util/logger'
|
||||
import { now } from './util/clock'
|
||||
import { jobQueue } from './worker/queue'
|
||||
|
||||
const schedule = (cronTime: string, onTick: () => void) => {
|
||||
@@ -27,7 +28,7 @@ export const refreshCloseToGraduating = async () => {
|
||||
const updatedData = await findStudentsCloseToGraduation()
|
||||
await redisClient.set(
|
||||
CLOSE_TO_GRADUATION_REDIS_KEY,
|
||||
JSON.stringify({ ...updatedData, lastUpdated: new Date().toISOString() })
|
||||
JSON.stringify({ ...updatedData, lastUpdated: now().toISOString() })
|
||||
)
|
||||
logger.info('Students close to graduating updated!')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
import { runningInCI } from '../config'
|
||||
import { clockStorage } from '../util/clock'
|
||||
import logger from '../util/logger'
|
||||
|
||||
export function testTimeMiddleware(req: Request, _res: Response, next: NextFunction) {
|
||||
const testNow = req.header('x-test-now')
|
||||
|
||||
if (runningInCI && testNow) {
|
||||
logger.info(`Setting test time: ${testNow}`)
|
||||
return clockStorage.run({ now: new Date(testNow) }, next)
|
||||
}
|
||||
|
||||
return clockStorage.run({}, next)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import * as auth from './middleware/auth'
|
||||
import currentUserMiddleware from './middleware/currentUser'
|
||||
import errorHandler from './middleware/errorHandler'
|
||||
import headersMiddleware from './middleware/headers'
|
||||
import { testTimeMiddleware } from './middleware/testTime'
|
||||
import banners from './routes/banners'
|
||||
import changelog from './routes/changelog'
|
||||
import closeToGraduation from './routes/closeToGraduation'
|
||||
@@ -43,6 +44,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(compression())
|
||||
|
||||
app.use(headersMiddleware)
|
||||
app.use(testTimeMiddleware)
|
||||
app.use(currentUserMiddleware)
|
||||
app.use(accessLogger)
|
||||
app.use(`${url}/login`, login)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from 'express'
|
||||
|
||||
import { Release } from '@oodikone/shared/types'
|
||||
import { isDev, isTest } from '../config'
|
||||
import { now } from '../util/clock'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -18,19 +19,19 @@ router.get<never, Release[]>('/', async (_, res) => {
|
||||
{
|
||||
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
|
||||
title: 'Release 3',
|
||||
time: new Date().toISOString(),
|
||||
time: now().toISOString(),
|
||||
version: '0.0.3',
|
||||
},
|
||||
{
|
||||
description: "Let's not spam the GitHub API in development!",
|
||||
title: 'Release 2',
|
||||
time: new Date().toISOString(),
|
||||
time: now().toISOString(),
|
||||
version: '0.0.2',
|
||||
},
|
||||
{
|
||||
description: 'This release should not be visible on the frontpage',
|
||||
title: 'Release 1',
|
||||
time: new Date().toISOString(),
|
||||
time: now().toISOString(),
|
||||
version: '0.0.1',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -56,7 +56,7 @@ export type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearly
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { codes, combineSubstitutions, separate } = req.query
|
||||
const { codes, combineSubstitutions, separate, fromYearCode, toYearCode } = req.query
|
||||
|
||||
const courseCodes = handleQueryArrays(codes)
|
||||
|
||||
@@ -82,7 +82,14 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
|
||||
const useCombineSubstitutions = combineSubstitutions !== 'false'
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(courseCodes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
const results = await getCourseYearlyStats(
|
||||
courseCodes,
|
||||
useSeparate,
|
||||
anonymizationSalt,
|
||||
useCombineSubstitutions,
|
||||
fromYearCode,
|
||||
toYearCode
|
||||
)
|
||||
return res.json(results)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getStudentTagMap } from '../services/populations/getStudentData'
|
||||
import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { findByCourseAndSemesters, getStartAndEndDates } from '../services/students'
|
||||
import { getFullStudyProgrammeRights, handleQueryArrays, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
@@ -150,8 +150,9 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
|
||||
|
||||
const studyRights = [programme]
|
||||
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
|
||||
const filterCreditsAndEnrollmentsByDate = false
|
||||
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap, startDate)
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap, filterCreditsAndEnrollmentsByDate, startDate)
|
||||
const processed = obfuscateStuff({
|
||||
result,
|
||||
|
||||
@@ -208,9 +209,19 @@ router.get<
|
||||
? new Set(studentNumbers)
|
||||
: new Set(allStudentsUserCanAccess)
|
||||
|
||||
const { startDate: startDate, endDate: endDate } = await getStartAndEndDates(Number(from), Number(to), isSeparate)
|
||||
|
||||
const studyRights = []
|
||||
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap)
|
||||
const filterCreditsAndEnrollmentsByDate = true
|
||||
const result = await statisticsOf(
|
||||
studentNumbers,
|
||||
studyRights,
|
||||
tagMap,
|
||||
filterCreditsAndEnrollmentsByDate,
|
||||
startDate?.toISOString(),
|
||||
endDate?.toISOString()
|
||||
)
|
||||
const processed = obfuscateStuff({
|
||||
result,
|
||||
|
||||
@@ -243,12 +254,19 @@ router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
|
||||
|
||||
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
|
||||
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
|
||||
const filterCreditsAndEnrollmentsByDate = false
|
||||
|
||||
const { startDate } = tags?.year
|
||||
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
|
||||
: { startDate: undefined }
|
||||
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
|
||||
const result = await statisticsOf(
|
||||
filteredStudentNumbers,
|
||||
studyRights,
|
||||
tagMap,
|
||||
filterCreditsAndEnrollmentsByDate,
|
||||
startDate
|
||||
)
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(students, filteredStudentNumbers)
|
||||
@@ -286,13 +304,13 @@ router.get<
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
|
||||
>('/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
return res.json({
|
||||
openCourses: maxYearsToCreatePopulationFromOpen,
|
||||
uniCourses: maxYearsToCreatePopulationFromUni,
|
||||
unifyCourses: maxYearsToCreatePopulationFromBoth,
|
||||
uniCourses: maxYearsToCreatePopulationFromUni,
|
||||
openCourses: maxYearsToCreatePopulationFromOpen,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
|
||||
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
|
||||
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
|
||||
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
|
||||
import { now } from '../util/clock'
|
||||
import logger from '../util/logger'
|
||||
import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
import { yearToYearCode } from '@oodikone/shared/util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -103,16 +105,25 @@ router.get<StudyProgrammeParams, unknown, never, StatsRequestQuery>('/:id/gradua
|
||||
type CourseStatsQuery = {
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
fromYearCode?: string
|
||||
toYearCode?: string
|
||||
}
|
||||
|
||||
router.get<StudyProgrammeParams, unknown, never, CourseStatsQuery>('/:id/coursestats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
const { combinedProgramme, yearType } = req.query
|
||||
const date = new Date()
|
||||
const { combinedProgramme, yearType, fromYearCode, toYearCode } = req.query
|
||||
const date = now()
|
||||
date.setHours(23, 59, 59, 999)
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
try {
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, yearType, combinedProgramme)
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(
|
||||
date.getTime(),
|
||||
code,
|
||||
yearType,
|
||||
combinedProgramme,
|
||||
fromYearCode ?? '1', // 1950
|
||||
toYearCode ?? yearToYearCode(now().getFullYear()).toString() // Current year
|
||||
)
|
||||
return res.json(data)
|
||||
} catch (error) {
|
||||
logger.error({ message: `Failed to get code ${code} programme courses stats`, meta: `${error}` })
|
||||
|
||||
@@ -2,16 +2,17 @@ import { Op } from 'sequelize'
|
||||
import { Banner } from '@oodikone/shared/models/kone'
|
||||
import { BannerModel } from '../models/kone/banner'
|
||||
import { SetOptional } from '../types'
|
||||
import { now } from '../util/clock'
|
||||
|
||||
export const getActiveBanners = async () =>
|
||||
await BannerModel.findAll({
|
||||
attributes: ['id', 'text', 'lightness', 'color', 'startDate', 'endDate'],
|
||||
where: {
|
||||
endDate: {
|
||||
[Op.gt]: new Date(),
|
||||
[Op.gt]: now(),
|
||||
},
|
||||
startDate: {
|
||||
[Op.lt]: new Date(),
|
||||
[Op.lt]: now(),
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold, omitKeys } from '@oodikone/shared/util'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
|
||||
import { now } from '../util/clock'
|
||||
|
||||
type StudentWithStudyplanNested = Pick<
|
||||
StudentModel,
|
||||
@@ -83,7 +84,7 @@ const getPassedCredits = async (
|
||||
groupCreditCodes.includes(course.code) && groupCreditCodes.length === 1 ? null : groupCreditCodes,
|
||||
studentNumber,
|
||||
creditType: groupCredits.length > 1 ? CreditTypeCode.PASSED : groupCredits.at(0)!.credittypecode,
|
||||
date: groupCredits?.at(0)?.attainment_date ?? new Date(), // Credits are sorted by date in desc. order
|
||||
date: groupCredits?.at(0)?.attainment_date ?? now(), // Credits are sorted by date in desc. order
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -144,7 +145,7 @@ const getEnrollments = async (
|
||||
? null
|
||||
: groupEnrollmentCodes,
|
||||
studentNumber,
|
||||
date: groupEnrollments?.at(0)?.enrollment_date_time ?? new Date(), // Enrollments are sorted by date in desc. order
|
||||
date: groupEnrollments?.at(0)?.enrollment_date_time ?? now(), // Enrollments are sorted by date in desc. order
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
} from '../../models'
|
||||
import { now } from '../../util/clock'
|
||||
import { getIsOpen } from './helpers'
|
||||
|
||||
export const getCreditsForCourses = async (codeGroups: string[][], unification: Unification) => {
|
||||
export const getCreditsForCourses = async (codeGroups: string[][], unification: Unification, from: Date, to: Date) => {
|
||||
const allCourseCodes = codeGroups.flatMap(group => group)
|
||||
|
||||
// We need the credits grouped by student numbers so that we can check if a student has
|
||||
@@ -41,6 +42,7 @@ export const getCreditsForCourses = async (codeGroups: string[][], unification:
|
||||
},
|
||||
is_open: getIsOpen(unification),
|
||||
credittypecode: { [Op.not]: CreditTypeCode.IMPROVED }, // We do not care about improved grades
|
||||
attainment_date: { [Op.between]: [from, to] },
|
||||
},
|
||||
order: [['attainment_date', 'ASC']],
|
||||
include: [
|
||||
@@ -49,7 +51,7 @@ export const getCreditsForCourses = async (codeGroups: string[][], unification:
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
[Op.lte]: now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -134,7 +136,12 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const getEnrollmentsForCourses = async (codeGroups: string[][], unification: Unification) => {
|
||||
export const getEnrollmentsForCourses = async (
|
||||
codeGroups: string[][],
|
||||
unification: Unification,
|
||||
from: Date,
|
||||
to: Date
|
||||
) => {
|
||||
const allCourseCodes = codeGroups.flatMap(group => group)
|
||||
|
||||
const students = await StudentModel.findAll({
|
||||
@@ -151,7 +158,7 @@ export const getEnrollmentsForCourses = async (codeGroups: string[][], unificati
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
[Op.lte]: new Date(),
|
||||
[Op.lte]: now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -160,8 +167,13 @@ export const getEnrollmentsForCourses = async (codeGroups: string[][], unificati
|
||||
course_code: {
|
||||
[Op.in]: allCourseCodes,
|
||||
},
|
||||
// Date when OK changed from Oodi to Sisu data, studyright_id is null before that date
|
||||
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
[Op.between]: [from, to],
|
||||
// Date when OK changed from Oodi to Sisu data, studyright_id is null before that date
|
||||
[Op.gte]: enrollmentTimeDateThreshold,
|
||||
},
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Op, fn as dbFn, col as dbCol } from 'sequelize'
|
||||
|
||||
import { Credit, Enrollment } from '@oodikone/shared/models'
|
||||
import { Name, EnrollmentState, Unification } from '@oodikone/shared/types'
|
||||
import { enrollmentTimeDateThreshold, getSemesterCodeAt } from '@oodikone/shared/util'
|
||||
import { enrollmentTimeDateThreshold, getSemesterCodeAt, yearCodeToYear, yearToYearCode } from '@oodikone/shared/util'
|
||||
import { dateIsBetween } from '@oodikone/shared/util/datetime'
|
||||
import logger from '../../../src/util/logger'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
|
||||
import { isOpenUniCourseCode } from '../../util'
|
||||
import { now } from '../../util/clock'
|
||||
import { getSemestersAndYears, SemestersAndYears } from '../semesters'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
@@ -80,7 +81,7 @@ const parseCredit = (
|
||||
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
|
||||
studyRightElements
|
||||
.filter(({ startDate, endDate }) => dateIsBetween(attainmentDate, startDate, endDate))
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||
.sort((a, b) => (b.endDate?.getTime() ?? 0) - (a.endDate?.getTime() ?? 0))
|
||||
.at(0) // The newest studyRightElement
|
||||
|
||||
const programme = programmeOfCredit
|
||||
@@ -130,7 +131,7 @@ const parseEnrollment = (
|
||||
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
|
||||
studyRightElements
|
||||
.filter(({ startDate, endDate }) => dateIsBetween(enrollmentDateTime, startDate, endDate))
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||
.sort((a, b) => b.startDate?.getTime() - a.startDate?.getTime())
|
||||
.at(0) // The newest studyRightElement
|
||||
|
||||
const programme = programmeOfEnrollment
|
||||
@@ -169,7 +170,7 @@ const getSemesterAndYearByDate = (
|
||||
semesters: SemestersAndYears['semesters'],
|
||||
years: SemestersAndYears['years']
|
||||
) => {
|
||||
const semesterCode = getSemesterCodeAt(semesters, date) ?? getSemesterCodeAt(semesters, new Date())!
|
||||
const semesterCode = getSemesterCodeAt(semesters, date) ?? getSemesterCodeAt(semesters, now())!
|
||||
|
||||
const semester = semesters[semesterCode]
|
||||
const year = years[semester.yearcode]
|
||||
@@ -184,7 +185,9 @@ const getYearlyStatsOfNew = async (
|
||||
unification: Unification,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>,
|
||||
from: Date,
|
||||
to: Date
|
||||
) => {
|
||||
// Includes main course code and substitutions (if enabled)
|
||||
const creditGroupCodes =
|
||||
@@ -209,8 +212,8 @@ const getYearlyStatsOfNew = async (
|
||||
const { semesters, years } = await getSemestersAndYears()
|
||||
|
||||
const [creditGroups, enrollmentGroups] = await Promise.all([
|
||||
getCreditsForCourses(filteredCreditGroupCodes, unification),
|
||||
getEnrollmentsForCourses(filteredCreditGroupCodes, unification),
|
||||
getCreditsForCourses(filteredCreditGroupCodes, unification, from, to),
|
||||
getEnrollmentsForCourses(filteredCreditGroupCodes, unification, from, to),
|
||||
])
|
||||
|
||||
const counter = new CourseYearlyStatsCounter()
|
||||
@@ -330,9 +333,13 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
|
||||
if (lastAttainmentDate?.date == null) return 0
|
||||
|
||||
const newestAttainmentDate = lastAttainmentDate?.date.getFullYear()
|
||||
const attainmentDateThreshold = new Date(newestAttainmentDate - 6, 0, 1)
|
||||
/** Amount of years before latest attainment that the attainment count is calcucated for */
|
||||
const yearRange = 6
|
||||
|
||||
const newestAttainmentYear = lastAttainmentDate?.date.getFullYear()
|
||||
const attainmentDateThreshold = new Date(newestAttainmentYear - yearRange, 0, 1)
|
||||
|
||||
/** Amount of attainments between latest-attainment-year - yearRange and latest attainment date */
|
||||
const attainmentsWithinThreshold = await CreditModel.count({
|
||||
where: {
|
||||
course_code: { [Op.in]: courseCodes },
|
||||
@@ -341,21 +348,30 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
},
|
||||
})
|
||||
|
||||
// MAGIC NUMBER
|
||||
const maxAllowedAttainments = 1_000_000 // * Lower this value to get a smaller result if necessary
|
||||
return Math.max(1, maxAllowedAttainments / attainmentsWithinThreshold)
|
||||
/** Limit the allowed attainments to 15_000 during the $yearRange years */
|
||||
const maxAllowedAttainments = 15_000 * yearRange
|
||||
return Math.round(Math.max(1, maxAllowedAttainments / attainmentsWithinThreshold))
|
||||
}
|
||||
|
||||
export const getCourseYearlyStats = async (
|
||||
courseCodes: string[],
|
||||
separate: boolean,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean
|
||||
combineSubstitutions: boolean,
|
||||
fromYearCode = yearToYearCode(1950).toString(),
|
||||
toYearCode: string = yearToYearCode(now().getFullYear()).toString()
|
||||
) => {
|
||||
// Default to 1900 - currentYear+1 so that without parameters the api returns stats for all years
|
||||
const from = new Date(`${yearCodeToYear(fromYearCode)}-08-01`) // FALL
|
||||
const to = new Date(`${yearCodeToYear(toYearCode) + 1}-07-31`) // SPRING next year
|
||||
|
||||
const [credits, enrollments] = await Promise.all([
|
||||
CreditModel.findAll({
|
||||
attributes: ['student_studentnumber'],
|
||||
where: { course_code: { [Op.in]: courseCodes } },
|
||||
where: {
|
||||
course_code: { [Op.in]: courseCodes },
|
||||
attainment_date: { [Op.between]: [from, to] },
|
||||
},
|
||||
}),
|
||||
EnrollmentModel.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
@@ -364,7 +380,12 @@ export const getCourseYearlyStats = async (
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
enrollment_date_time: { [Op.gte]: enrollmentTimeDateThreshold },
|
||||
enrollment_date_time: {
|
||||
[Op.and]: {
|
||||
[Op.between]: [from, to],
|
||||
[Op.gte]: enrollmentTimeDateThreshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -384,9 +405,9 @@ export const getCourseYearlyStats = async (
|
||||
const statsRegular = await Promise.all(
|
||||
courseCodes.map(async courseCode => {
|
||||
const course: Pick<CourseModel, 'code' | 'name' | 'substitution_groups'> | null = await CourseModel.findOne({
|
||||
raw: true,
|
||||
attributes: ['code', 'name', 'substitution_groups'],
|
||||
where: { code: courseCode },
|
||||
raw: true,
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
@@ -402,7 +423,9 @@ export const getCourseYearlyStats = async (
|
||||
Unification.OPEN,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
@@ -411,7 +434,9 @@ export const getCourseYearlyStats = async (
|
||||
Unification.REGULAR,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
),
|
||||
getYearlyStatsOfNew(
|
||||
course,
|
||||
@@ -420,7 +445,9 @@ export const getCourseYearlyStats = async (
|
||||
Unification.UNIFY,
|
||||
anonymizationSalt,
|
||||
combineSubstitutions,
|
||||
studentNumberToSrElementsMap
|
||||
studentNumberToSrElementsMap,
|
||||
from,
|
||||
to
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
import { OrganizationModel } from '../../models'
|
||||
import { ApplicationError } from '../../util/customErrors'
|
||||
import { now } from '../../util/clock'
|
||||
import { CurriculumPeriods, getCurriculumPeriods } from '../curriculumPeriods'
|
||||
|
||||
const { sequelize } = dbConnections
|
||||
@@ -27,7 +28,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { validFrom: Date }> =
|
||||
await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
SELECT
|
||||
code,
|
||||
curriculum_period_ids as "curriculumPeriodIds",
|
||||
degree_programme_type as "degreeProgrammeType",
|
||||
@@ -64,7 +65,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
|
||||
for (const programmeVersions of Object.values(programmesGroupedByCode)) {
|
||||
// Programmes are ordered by valid_from in descending order, so the first one whose valid_from date isn't in the future, is the newest version
|
||||
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.validFrom)
|
||||
const newestProgrammeVersion = programmeVersions.find(prog => now() >= prog.validFrom)
|
||||
if (!newestProgrammeVersion) {
|
||||
continue
|
||||
}
|
||||
@@ -79,8 +80,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
.flat()
|
||||
const isRelevantProgramme =
|
||||
!onlyCurrentProgrammes ||
|
||||
(onlyCurrentProgrammes &&
|
||||
yearsOfProgramme.some(year => year.startDate <= new Date() && new Date() <= year.endDate))
|
||||
(onlyCurrentProgrammes && yearsOfProgramme.some(year => year.startDate <= now() && now() <= year.endDate))
|
||||
|
||||
if (isRelevantProgramme) {
|
||||
relevantProgrammes.push({
|
||||
@@ -98,7 +98,8 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
export type ProgrammesOfOrganization = ProgrammeModuleWithRelevantAttributes[]
|
||||
|
||||
export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurrentProgrammes: boolean) => {
|
||||
const organization = await OrganizationModel.findOne({
|
||||
const organization: Pick<OrganizationModel, 'id'> | null = await OrganizationModel.findOne({
|
||||
raw: true,
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
code: facultyCode,
|
||||
|
||||
@@ -16,7 +16,7 @@ const calculateCombinedStats = async (
|
||||
graphStats: [] as BasicStats['graphStats'],
|
||||
programmeTableStats: {} as Record<string, BasicStats['tableStats']>,
|
||||
tableStats: [] as BasicStats['tableStats'],
|
||||
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
|
||||
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated', 'Cancelled'],
|
||||
}
|
||||
if (includeAllSpecials) {
|
||||
studentInfo.titles.push('Transferred out\nof programme', 'Transferred into\nprogramme')
|
||||
@@ -103,7 +103,8 @@ const calculateCombinedStats = async (
|
||||
} else {
|
||||
for (let i = 0; i < tableStatsForProgramme.length; i++) {
|
||||
for (let j = 1; j < tableStatsForProgramme[i].length; j++) {
|
||||
;(combinedTableStats[i][j] as number) += tableStatsForProgramme[i][j]
|
||||
// @ts-expect-error the only string is in index 0 (the year label) which we skip over
|
||||
combinedTableStats[i][j] += tableStatsForProgramme[i][j]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +127,7 @@ const calculateCombinedStats = async (
|
||||
} else {
|
||||
for (let i = 0; i < stats.tableStats.length; i++) {
|
||||
for (let j = 1; j < stats.tableStats[i].length; j++) {
|
||||
// @ts-expect-error the only string is in index 0 (the year label) which we skip over
|
||||
studentInfo.tableStats[i][j] += stats.tableStats[i][j]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,11 +112,7 @@ export const combineFacultyStudentProgress = async (
|
||||
|
||||
for (const { code: studyProgramme } of programmes) {
|
||||
const programmeInfo = allDegreeProgrammes.find(programme => programme.code === studyProgramme)
|
||||
if (
|
||||
!programmeInfo ||
|
||||
programmeInfo.degreeProgrammeType == null ||
|
||||
!(programmeInfo.degreeProgrammeType in programmeTypes)
|
||||
) {
|
||||
if (!programmeInfo?.degreeProgrammeType || !(programmeInfo.degreeProgrammeType in programmeTypes)) {
|
||||
continue
|
||||
}
|
||||
const statsFromRedis = await getStudyTrackStats(studyProgramme, null, specialGroups)
|
||||
|
||||
@@ -17,9 +17,6 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
|
||||
programmeCode
|
||||
]
|
||||
|
||||
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === programmeCode)
|
||||
?.otherCountriesCount?.[programmeCode]
|
||||
|
||||
if (!statsForProgramme) continue
|
||||
|
||||
for (const yearStats of statsForProgramme) {
|
||||
@@ -30,7 +27,8 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
|
||||
if (facultyTableStats[yearStats[0]][i] == null) {
|
||||
facultyTableStats[yearStats[0]].push(yearStats[i])
|
||||
} else if (typeof facultyTableStats[yearStats[0]][i] === 'number') {
|
||||
;(facultyTableStats[yearStats[0]][i] as number) += yearStats[i] as number
|
||||
// @ts-expect-error values at given indices are numbers
|
||||
facultyTableStats[yearStats[0]][i] += yearStats[i]
|
||||
} else {
|
||||
const allStudentsCount = facultyTableStats[yearStats[0]][1]
|
||||
const categoryStudentsCount = facultyTableStats[yearStats[0]][i - 1]
|
||||
@@ -46,6 +44,9 @@ const calculateCombinedStats = (programmeCodes: string[], stats: StudyTrackStats
|
||||
}
|
||||
}
|
||||
|
||||
const programmeOtherCountriesCount = stats.find(programmeStats => programmeStats.id === programmeCode)
|
||||
?.otherCountriesCount?.[programmeCode]
|
||||
|
||||
if (!programmeOtherCountriesCount) continue
|
||||
for (const [year, countryStats] of Object.entries(programmeOtherCountriesCount)) {
|
||||
facultyTableStatsExtra[year] ??= {}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const GraduationTarget = {
|
||||
export const countTimeCategories = (times: number[], target: number) => {
|
||||
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
|
||||
|
||||
for (const time of times) {
|
||||
for (const time of times ?? []) {
|
||||
if (time <= target) {
|
||||
statistics.onTime++
|
||||
} else if (time <= target + GraduationTarget.ONE_YEAR) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
} from '../../models'
|
||||
import { now } from '../../util/clock'
|
||||
import { redisClient } from '../redis'
|
||||
import { getCurrentSemester } from '../semesters'
|
||||
import { getCurriculumVersion } from './shared'
|
||||
@@ -176,7 +177,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
where: {
|
||||
graduated: false,
|
||||
endDate: {
|
||||
[Op.gte]: new Date(),
|
||||
[Op.gte]: now(),
|
||||
},
|
||||
'$studyplans.programme_code$': {
|
||||
[Op.eq]: col('studyplans->studyRight->studyRightElements.code'),
|
||||
@@ -289,7 +290,7 @@ export const getCloseToGraduationData = async (studentNumbers?: string[]) => {
|
||||
|
||||
const students = await findStudentsCloseToGraduation()
|
||||
|
||||
const freshData = { ...students, lastUpdated: new Date().toISOString() }
|
||||
const freshData = { ...students, lastUpdated: now().toISOString() }
|
||||
await redisClient.set(CLOSE_TO_GRADUATION_REDIS_KEY, JSON.stringify(freshData))
|
||||
|
||||
return freshData
|
||||
|
||||
@@ -50,8 +50,12 @@ export type StudentEnrollment = Pick<
|
||||
Enrollment,
|
||||
'course_code' | 'state' | 'enrollment_date_time' | 'semestercode' | 'studentnumber' | 'studyright_id'
|
||||
>
|
||||
export const getEnrollments = (studentNumbers: string[], startDate: string): Promise<Array<StudentEnrollment>> => {
|
||||
return EnrollmentModel.findAll({
|
||||
export const getEnrollments = (
|
||||
studentNumbers: string[],
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<Array<StudentEnrollment>> =>
|
||||
EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'state', 'enrollment_date_time', 'semestercode', 'studentnumber', 'studyright_id'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
@@ -59,12 +63,11 @@ export const getEnrollments = (studentNumbers: string[], startDate: string): Pro
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
enrollment_date_time: {
|
||||
[Op.gte]: dateMaxFromList(new Date(startDate), enrollmentTimeDateThreshold),
|
||||
[Op.between]: [dateMaxFromList(new Date(startDate), enrollmentTimeDateThreshold), new Date(endDate)],
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
}
|
||||
|
||||
type StudentPersonalData = Pick<
|
||||
Student,
|
||||
@@ -195,8 +198,13 @@ export type StudentCredit = Pick<
|
||||
| 'studyright_id'
|
||||
>
|
||||
|
||||
export const getCredits = async (studentNumbers: string[]): Promise<Array<StudentCredit>> =>
|
||||
export const getCredits = async (
|
||||
studentNumbers: string[],
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<Array<StudentCredit>> =>
|
||||
CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: [
|
||||
'grade',
|
||||
'credits',
|
||||
@@ -210,8 +218,8 @@ export const getCredits = async (studentNumbers: string[]): Promise<Array<Studen
|
||||
],
|
||||
where: {
|
||||
student_studentnumber: { [Op.in]: studentNumbers },
|
||||
attainment_date: { [Op.between]: [startDate, endDate] },
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
export const getStudyRightElementsForStudyRight = async (studentNumbers: string[], code: string) =>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const parseDateRangeFromParams = (query: QueryParams): ParsedQueryParams
|
||||
|
||||
return { startDate, endDate }
|
||||
}
|
||||
|
||||
export const getCurriculumVersion = (curriculumPeriodId: string | undefined) => {
|
||||
if (!curriculumPeriodId) {
|
||||
return null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDegreeProgrammeType } from '../../util'
|
||||
import { now } from '../../util/clock'
|
||||
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
|
||||
import { formatStudentForAPI } from './formatStatisticsForApi'
|
||||
import {
|
||||
@@ -30,16 +31,33 @@ export const statisticsOf = async (
|
||||
studentNumbers: string[],
|
||||
studyRights: string[],
|
||||
tagMap: Map<string, StudentTags[]>,
|
||||
startDate?: string
|
||||
filterByDates: boolean,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
) => {
|
||||
const defaultStartDate = new Date(1900, 0, 1).toISOString()
|
||||
const defaultEndDate = new Date(now().getFullYear() + 1, 0, 1).toISOString()
|
||||
|
||||
const code = studyRights[0] ?? ''
|
||||
const mockedStartDate = startDate ?? new Date(1900, 0, 1).toISOString()
|
||||
const mockedStartDate = startDate ?? defaultStartDate
|
||||
const mockedEndDate = endDate ?? defaultEndDate
|
||||
|
||||
const [students, enrollments, credits, degreeProgrammeType, criteria, studyRightElementsForStudyRight] =
|
||||
await Promise.all([
|
||||
getStudents(studentNumbers),
|
||||
getEnrollments(studentNumbers, mockedStartDate),
|
||||
getCredits(studentNumbers),
|
||||
|
||||
// NOTE: If filterByDates is set, use default values for filtering credits and enrollments
|
||||
getEnrollments(
|
||||
studentNumbers,
|
||||
filterByDates ? mockedStartDate : defaultStartDate,
|
||||
filterByDates ? mockedEndDate : defaultEndDate
|
||||
),
|
||||
getCredits(
|
||||
studentNumbers,
|
||||
filterByDates ? mockedStartDate : defaultStartDate,
|
||||
filterByDates ? mockedEndDate : defaultEndDate
|
||||
),
|
||||
|
||||
getDegreeProgrammeType(code),
|
||||
getCriteria(code),
|
||||
getStudyRightElementsForStudyRight(studentNumbers, code),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Op } from 'sequelize'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { dateMinFromList, dateMaxFromList } from '@oodikone/shared/util/datetime'
|
||||
import { SemesterModel } from '../models'
|
||||
import { now } from '../util/clock'
|
||||
|
||||
export type SemestersAndYears = {
|
||||
years: Record<
|
||||
@@ -39,7 +40,7 @@ export const getSemesterNamesByCode = async (semesterCodes: number[]): Promise<R
|
||||
}
|
||||
|
||||
export const getCurrentSemester = async () => {
|
||||
const today = new Date()
|
||||
const today = now()
|
||||
const currentSemester = await SemesterModel.findOne({
|
||||
where: {
|
||||
startdate: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Op } from 'sequelize'
|
||||
|
||||
import { SECRET_TOKEN, SIS_UPDATER_URL } from '../config'
|
||||
import { StudyplanModel } from '../models'
|
||||
import { now } from '../util/clock'
|
||||
|
||||
const client = axios.create({ baseURL: SIS_UPDATER_URL })
|
||||
const params = { params: { token: SECRET_TOKEN } }
|
||||
@@ -50,7 +51,7 @@ export const nukeRedis = async () => {
|
||||
}
|
||||
|
||||
export const updateSISStudyPlans = async (days: number) => {
|
||||
const limitDate = new Date()
|
||||
const limitDate = now()
|
||||
limitDate.setDate(limitDate.getDate() - days)
|
||||
const result = await StudyplanModel.findAll({
|
||||
where: { updatedAt: { [Op.lte]: limitDate } },
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { Credit, Student } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode, EnrollmentState, UnifyStatus } from '@oodikone/shared/types'
|
||||
import { FormattedStudentForSearch, StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { enrollmentTimeDateThresholdAcademicYear, splitByEmptySpace } from '@oodikone/shared/util'
|
||||
import { dateIsBetween, dateMaxFromList } from '@oodikone/shared/util/datetime'
|
||||
import { dateMaxFromList } from '@oodikone/shared/util/datetime'
|
||||
import { now } from '../util/clock'
|
||||
import {
|
||||
StudentModel,
|
||||
CreditModel,
|
||||
@@ -122,26 +123,12 @@ const getUnifyStatus = (unifyCourses: UnifyStatus): [boolean] | [true, false] =>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns students whose latest "active" attainment/enrollment is between to and from.
|
||||
|
||||
NOTE: If you have failed a course in 2021 and passed the same course in 2023
|
||||
you would not be included in from=2020 to=2022 query
|
||||
|
||||
*/
|
||||
export const findByCourseAndSemesters = async (
|
||||
codes: string[],
|
||||
from: number,
|
||||
to: number,
|
||||
separate: boolean,
|
||||
unifyCourses: UnifyStatus = 'unifyStats'
|
||||
) => {
|
||||
/* from & to are semestercodes if separate = false, or yearcodes in case separate is true. */
|
||||
export const getStartAndEndDates = async (from: number, to: number, separate: boolean) => {
|
||||
const startSemester = await SemesterModel.findOne({
|
||||
where: {
|
||||
[separate ? 'semestercode' : 'yearcode']: from,
|
||||
},
|
||||
order: ['semestercode'],
|
||||
order: [['semestercode', 'ASC']],
|
||||
limit: 1,
|
||||
raw: true,
|
||||
})
|
||||
@@ -156,11 +143,28 @@ export const findByCourseAndSemesters = async (
|
||||
})
|
||||
|
||||
if (!startSemester || !endSemester) {
|
||||
return []
|
||||
return {}
|
||||
}
|
||||
|
||||
const { startdate } = startSemester
|
||||
const { enddate } = endSemester
|
||||
const { startdate: startDate } = startSemester
|
||||
const { enddate: endDate } = endSemester
|
||||
|
||||
return { startDate, endDate }
|
||||
}
|
||||
|
||||
/**
|
||||
Returns students who have an attainment/enrollment between to and from.
|
||||
*/
|
||||
export const findByCourseAndSemesters = async (
|
||||
codes: string[],
|
||||
from: number,
|
||||
to: number,
|
||||
separate: boolean,
|
||||
unifyCourses: UnifyStatus = 'unifyStats'
|
||||
) => {
|
||||
/* from & to are semestercodes if separate = false, or yearcodes in case separate is true. */
|
||||
|
||||
const { startDate: startDate, endDate: endDate } = await getStartAndEndDates(from, to, separate)
|
||||
|
||||
const unifyStatus = getUnifyStatus(unifyCourses)
|
||||
|
||||
@@ -175,6 +179,7 @@ export const findByCourseAndSemesters = async (
|
||||
where: {
|
||||
course_code: { [Op.in]: codes },
|
||||
is_open: unifyStatus,
|
||||
attainment_date: { [Op.between]: [startDate, endDate] },
|
||||
credittypecode: { [Op.not]: CreditTypeCode.IMPROVED }, // We do not care about improved grades
|
||||
},
|
||||
order: [
|
||||
@@ -195,6 +200,9 @@ export const findByCourseAndSemesters = async (
|
||||
where: {
|
||||
course_code: { [Op.in]: codes },
|
||||
is_open: unifyStatus,
|
||||
enrollment_date_time: {
|
||||
[Op.between]: [dateMaxFromList(startDate ?? new Date(0), enrollmentTimeDateThresholdAcademicYear), endDate],
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
order: [['enrollment_date_time', 'DESC']],
|
||||
@@ -236,26 +244,11 @@ export const findByCourseAndSemesters = async (
|
||||
entry => entry.type === 'enrollment' && codes.includes(entry.course_code)
|
||||
)
|
||||
|
||||
if (
|
||||
latestCredit &&
|
||||
passedCreditTypes.includes(latestCredit.credittypecode!) &&
|
||||
dateIsBetween(latestCredit.date, startdate, enddate)
|
||||
) {
|
||||
if (latestCredit && passedCreditTypes.includes(latestCredit.credittypecode!)) {
|
||||
filteredStudentNumbers.add(studentNumber)
|
||||
} else if (
|
||||
latestCredit &&
|
||||
latestCredit.credittypecode === CreditTypeCode.FAILED &&
|
||||
dateIsBetween(latestCredit.date, startdate, enddate)
|
||||
) {
|
||||
} else if (latestCredit?.credittypecode === CreditTypeCode.FAILED) {
|
||||
filteredStudentNumbers.add(studentNumber)
|
||||
} else if (
|
||||
latestEnrollment &&
|
||||
dateIsBetween(
|
||||
latestEnrollment.date,
|
||||
dateMaxFromList(startdate, enrollmentTimeDateThresholdAcademicYear)!,
|
||||
enddate
|
||||
)
|
||||
) {
|
||||
} else if (latestEnrollment) {
|
||||
filteredStudentNumbers.add(studentNumber)
|
||||
}
|
||||
})
|
||||
@@ -393,7 +386,7 @@ export const bySearchTermAndStudentNumbers = async (searchTerm: string, studentN
|
||||
required: true,
|
||||
where: {
|
||||
endDate: {
|
||||
[Op.gte]: new Date(),
|
||||
[Op.gte]: now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,13 @@ export const getAllProgrammeCourses = async (providerCode: string) => {
|
||||
return courses
|
||||
}
|
||||
|
||||
export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, programmeCourses: string[]) => {
|
||||
export const getNotCompletedForProgrammeCourses = async (
|
||||
from: Date,
|
||||
to: Date,
|
||||
programmeCourses: string[],
|
||||
start: Date,
|
||||
end: Date
|
||||
) => {
|
||||
try {
|
||||
const enrollments: Array<
|
||||
Pick<EnrollmentModel, 'studentnumber' | 'course_code' | 'enrollment_date_time' | 'course'>
|
||||
@@ -46,7 +52,10 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
|
||||
[Op.in]: programmeCourses,
|
||||
},
|
||||
enrollment_date_time: {
|
||||
[Op.gte]: enrollmentTimeDateThresholdAcademicYear, // This has to be Academic year to match with Course statistics which doesn't display enrollments for ...-2021
|
||||
[Op.and]: {
|
||||
[Op.between]: [start, end],
|
||||
[Op.gte]: enrollmentTimeDateThresholdAcademicYear, // This has to be Academic year to match with Course statistics which doesn't display enrollments for ...-2021
|
||||
},
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
@@ -66,6 +75,9 @@ export const getNotCompletedForProgrammeCourses = async (from: Date, to: Date, p
|
||||
course_code: {
|
||||
[Op.in]: programmeCourses,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.between]: [start, end],
|
||||
},
|
||||
},
|
||||
order: [
|
||||
['attainment_date', 'DESC'],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { indexOf } from 'lodash-es'
|
||||
import { Name, EnrollmentType, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
import { BasicStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { now } from '../../util/clock'
|
||||
import {
|
||||
defineYear,
|
||||
getStartDate,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
tableTitles,
|
||||
} from './studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from './studyRightFinders'
|
||||
import { SISStudyRight } from '@oodikone/shared/models'
|
||||
|
||||
export const getDateOfFirstSemesterPresent = (
|
||||
semesterEnrollments: SemesterEnrollment[] | null,
|
||||
@@ -45,14 +47,14 @@ const getStartedStats = async ({
|
||||
isAcademicYear,
|
||||
}: {
|
||||
studyProgramme: string
|
||||
years: number[]
|
||||
years: number[] | string[]
|
||||
isAcademicYear: boolean
|
||||
}) => {
|
||||
const studyRightsOfProgramme = await getStudyRightsInProgramme(studyProgramme, false)
|
||||
const startedStudying = getStatsBasis(years)
|
||||
const accepted = getStatsBasis(years)
|
||||
const { semesters } = await getSemestersAndYears()
|
||||
const { semestercode: currentSemester } = Object.values(semesters).find(semester => semester.enddate >= new Date())!
|
||||
const { semestercode: currentSemester } = Object.values(semesters).find(semester => semester.enddate >= now())!
|
||||
|
||||
for (const studyRight of studyRightsOfProgramme) {
|
||||
const studyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
|
||||
@@ -68,7 +70,7 @@ const getStartedStats = async ({
|
||||
const startedInProgramme = new Date(studyRightElement.startDate)
|
||||
const startedInProgrammeYear = defineYear(startedInProgramme, isAcademicYear)
|
||||
|
||||
if (startedInProgramme >= new Date()) {
|
||||
if (startedInProgramme >= now()) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ export const getGraduatedStats = async ({
|
||||
includeAllSpecials,
|
||||
}: {
|
||||
studyProgramme: string
|
||||
years: number[]
|
||||
years: number[] | string[]
|
||||
isAcademicYear: boolean
|
||||
includeAllSpecials: boolean
|
||||
}) => {
|
||||
@@ -127,14 +129,14 @@ export const getGraduatedStats = async ({
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredStats = async ({
|
||||
const getTransferAndCancellationStats = async ({
|
||||
studyProgramme,
|
||||
years,
|
||||
isAcademicYear,
|
||||
combinedProgramme,
|
||||
}: {
|
||||
studyProgramme: string
|
||||
years: number[]
|
||||
years: number[] | string[]
|
||||
isAcademicYear: boolean
|
||||
combinedProgramme: string
|
||||
}) => {
|
||||
@@ -142,10 +144,20 @@ const getTransferredStats = async ({
|
||||
const secondStudyRights = combinedProgramme ? await getStudyRightsInProgramme(combinedProgramme, false) : []
|
||||
const transferredAway = getStatsBasis(years)
|
||||
const transferredTo = getStatsBasis(years)
|
||||
const cancelled = getStatsBasis(years)
|
||||
|
||||
const calculateTransferredStats = (studyRights, programme: string) => {
|
||||
const calculateStats = (studyRights: SISStudyRight[], programme: string, includeCancelled = false) => {
|
||||
const currentTime = now()
|
||||
for (const studyRight of studyRights) {
|
||||
const studyRightElement = studyRight.studyRightElements.find(element => element.code === programme)
|
||||
if (includeCancelled && studyRight.cancelled) {
|
||||
// Cancellation is per study right (not element) -> only count it once
|
||||
// endDate is always defined for cancelled study rights
|
||||
const cancellationYear = defineYear(studyRight.endDate, isAcademicYear)
|
||||
cancelled.graphStats[indexOf(years, cancellationYear)]++
|
||||
cancelled.tableStats[cancellationYear]++
|
||||
}
|
||||
|
||||
const studyRightElement = studyRight.studyRightElements.find(element => element.code === programme)!
|
||||
const studyRightElementsWithSamePhase = getStudyRightElementsWithPhase(studyRight, studyRightElement.phase)
|
||||
if (studyRightElementsWithSamePhase.length === 1) {
|
||||
continue
|
||||
@@ -153,14 +165,14 @@ const getTransferredStats = async ({
|
||||
const [firstStudyRightElementWithSamePhase] = studyRightElementsWithSamePhase
|
||||
if (
|
||||
firstStudyRightElementWithSamePhase.code !== studyRightElement.code &&
|
||||
studyRightElement.startDate <= new Date()
|
||||
studyRightElement.startDate <= currentTime
|
||||
) {
|
||||
const transferYear = defineYear(studyRightElement.startDate, isAcademicYear)
|
||||
transferredTo.graphStats[indexOf(years, transferYear)] += 1
|
||||
transferredTo.tableStats[transferYear] += 1
|
||||
} else if (
|
||||
firstStudyRightElementWithSamePhase.code === studyRightElement.code &&
|
||||
studyRightElement.endDate <= new Date()
|
||||
studyRightElement.endDate <= currentTime
|
||||
) {
|
||||
const transferYear = defineYear(studyRightElement.endDate, isAcademicYear)
|
||||
transferredAway.graphStats[indexOf(years, transferYear)] += 1
|
||||
@@ -169,10 +181,10 @@ const getTransferredStats = async ({
|
||||
}
|
||||
}
|
||||
|
||||
calculateTransferredStats(studyRights, studyProgramme)
|
||||
calculateTransferredStats(secondStudyRights, combinedProgramme)
|
||||
calculateStats(studyRights, studyProgramme, true)
|
||||
calculateStats(secondStudyRights, combinedProgramme)
|
||||
|
||||
return { transferredAway, transferredTo }
|
||||
return { cancelled, transferredAway, transferredTo }
|
||||
}
|
||||
|
||||
type Stats = {
|
||||
@@ -188,11 +200,12 @@ const initializeGraphStats = (
|
||||
accepted: Stats,
|
||||
acceptedSecondProg: Stats,
|
||||
graduated: Stats,
|
||||
cancelled: Stats,
|
||||
transferredAway: Stats,
|
||||
transferredTo: Stats,
|
||||
graduatedSecondProg: Stats
|
||||
) => {
|
||||
const basicTable = [] as Array<{ name: string; data: number[] }>
|
||||
const basicTable = [] as { name: string; data: number[] }[]
|
||||
if (combinedProgramme) {
|
||||
basicTable.push(
|
||||
{ name: 'Started studying bachelor', data: started.graphStats },
|
||||
@@ -200,13 +213,15 @@ const initializeGraphStats = (
|
||||
{ name: 'Accepted bachelor', data: accepted.graphStats },
|
||||
{ name: 'Accepted licentiate', data: acceptedSecondProg.graphStats },
|
||||
{ name: 'Graduated bachelor', data: graduated.graphStats },
|
||||
{ name: 'Graduated licentiate', data: graduatedSecondProg.graphStats }
|
||||
{ name: 'Graduated licentiate', data: graduatedSecondProg.graphStats },
|
||||
{ name: 'Cancelled', data: cancelled.graphStats }
|
||||
)
|
||||
} else {
|
||||
basicTable.push(
|
||||
{ name: 'Started studying', data: started.graphStats },
|
||||
{ name: 'Accepted', data: accepted.graphStats },
|
||||
{ name: 'Graduated', data: graduated.graphStats }
|
||||
{ name: 'Graduated', data: graduated.graphStats },
|
||||
{ name: 'Cancelled', data: cancelled.graphStats }
|
||||
)
|
||||
}
|
||||
if (includeAllSpecials) {
|
||||
@@ -219,7 +234,7 @@ const initializeGraphStats = (
|
||||
}
|
||||
|
||||
const initializeTableStats = (
|
||||
year: number,
|
||||
year: string | number,
|
||||
combinedProgramme: string,
|
||||
includeAllSpecials: boolean,
|
||||
started: Stats,
|
||||
@@ -228,6 +243,7 @@ const initializeTableStats = (
|
||||
acceptedSecondProg: Stats,
|
||||
graduated: Stats,
|
||||
graduatedSecondProg: Stats,
|
||||
cancelled: Stats,
|
||||
transferredAway: Stats,
|
||||
transferredTo: Stats
|
||||
) => {
|
||||
@@ -240,6 +256,7 @@ const initializeTableStats = (
|
||||
acceptedSecondProg.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
graduatedSecondProg.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
transferredAway.tableStats[year],
|
||||
transferredTo.tableStats[year],
|
||||
]
|
||||
@@ -253,6 +270,7 @@ const initializeTableStats = (
|
||||
acceptedSecondProg.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
graduatedSecondProg.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
]
|
||||
}
|
||||
if (includeAllSpecials) {
|
||||
@@ -261,11 +279,18 @@ const initializeTableStats = (
|
||||
started.tableStats[year],
|
||||
accepted.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
transferredAway.tableStats[year],
|
||||
transferredTo.tableStats[year],
|
||||
]
|
||||
}
|
||||
return [year, started.tableStats[year], accepted.tableStats[year], graduated.tableStats[year]]
|
||||
return [
|
||||
year,
|
||||
started.tableStats[year],
|
||||
accepted.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
]
|
||||
}
|
||||
|
||||
export const getBasicStatsForStudytrack = async ({
|
||||
@@ -282,7 +307,7 @@ export const getBasicStatsForStudytrack = async ({
|
||||
}): Promise<BasicStats> => {
|
||||
const { includeAllSpecials, isAcademicYear } = settings
|
||||
const since = getStartDate(isAcademicYear)
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear) as number[]
|
||||
const years = getYearsArray(since.getFullYear(), isAcademicYear) as string[] | number[]
|
||||
const queryParameters = { studyProgramme, years, isAcademicYear, includeAllSpecials, combinedProgramme }
|
||||
const queryParametersCombinedProg = { studyProgramme: combinedProgramme, years, isAcademicYear, includeAllSpecials }
|
||||
const { startedStudying, accepted } = await getStartedStats(queryParameters)
|
||||
@@ -291,11 +316,11 @@ export const getBasicStatsForStudytrack = async ({
|
||||
|
||||
const graduated = await getGraduatedStats(queryParameters)
|
||||
const graduatedSecondProg = await getGraduatedStats(queryParametersCombinedProg)
|
||||
const { transferredAway, transferredTo } = await getTransferredStats(queryParameters)
|
||||
const { cancelled, transferredAway, transferredTo } = await getTransferAndCancellationStats(queryParameters)
|
||||
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse() as number[]
|
||||
const reversedYears = getYearsArray(since.getFullYear(), isAcademicYear).reverse() as string[] | number[]
|
||||
const key = includeAllSpecials ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED'
|
||||
const titles = tableTitles.basics[combinedProgramme ? `${key}_COMBINED_PROGRAMME` : key] as string[]
|
||||
const titles: string[] = tableTitles.basics[combinedProgramme ? `${key}_COMBINED_PROGRAMME` : key]
|
||||
const tableStats = reversedYears.map(year =>
|
||||
initializeTableStats(
|
||||
year,
|
||||
@@ -307,6 +332,7 @@ export const getBasicStatsForStudytrack = async ({
|
||||
acceptedSecondProg,
|
||||
graduated,
|
||||
graduatedSecondProg,
|
||||
cancelled,
|
||||
transferredAway,
|
||||
transferredTo
|
||||
)
|
||||
@@ -323,6 +349,7 @@ export const getBasicStatsForStudytrack = async ({
|
||||
accepted,
|
||||
acceptedSecondProg,
|
||||
graduated,
|
||||
cancelled,
|
||||
transferredAway,
|
||||
transferredTo,
|
||||
graduatedSecondProg
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user