Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6bd62cfa | |||
| 588de98262 | |||
| d6061ecb38 | |||
| d04e7e6cef | |||
| 6e72f8b6ca | |||
| 94fb38e038 | |||
| 4c989928f1 | |||
| c14864aa29 | |||
| 229edd42e9 | |||
| 7c682373a5 |
+143
-42
@@ -1,20 +1,25 @@
|
||||
name: CI compile & test & build
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backend_image:
|
||||
description: 'Backend image to use in E2E tests (optional, default: ghcr.io/morbalint/kemkas-backend:b8565f8e)'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
ci-fe:
|
||||
unit-test-and-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js 18.x
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 24.x
|
||||
- name: Cache NPM packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -27,52 +32,148 @@ jobs:
|
||||
- name: Yarn Install
|
||||
run: |
|
||||
cd ./frontend
|
||||
yarn install --frozen-lockfiles
|
||||
yarn install --frozen-lockfile
|
||||
- name: Yarn Test
|
||||
run: |
|
||||
cd ./frontend
|
||||
./compile-less.sh
|
||||
yarn run test
|
||||
- name: Yarn Lint
|
||||
run: |
|
||||
cd ./frontend
|
||||
yarn run lint
|
||||
|
||||
docker-fe-build:
|
||||
build-fe:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
outputs:
|
||||
image_tag: ${{ steps.get_image_tag.outputs.image_tag }}
|
||||
image_sha_tag: ${{ steps.image-tag.outputs.image_sha_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
- id: get_image_tag
|
||||
run: |
|
||||
echo "GITHUB_SHA_SHORT=$(echo ${GITHUB_SHA::8})" >> $GITHUB_ENV
|
||||
echo "ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${GITHUB_SHA::8}"
|
||||
echo "image_tag=ghcr.io/${{ github.repository_owner }}/kemkas-fe:${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend/
|
||||
file: ./frontend/Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${{ env.GITHUB_SHA_SHORT }}
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compute frontend image tag
|
||||
id: image-tag
|
||||
run: |
|
||||
image_repo="ghcr.io/${{ github.repository_owner }}/kemkas-frontend"
|
||||
sha_tag="${image_repo}:${GITHUB_SHA::8}"
|
||||
|
||||
deploy-to-beta:
|
||||
tags="$sha_tag"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=$'\n'"${image_repo}:latest"
|
||||
fi
|
||||
|
||||
echo "image_sha_tag=${sha_tag}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$tags"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./frontend/
|
||||
file: ./frontend/Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-tag.outputs.tags }}
|
||||
|
||||
build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
outputs:
|
||||
image_sha_tag: ${{ steps.image-tag.outputs.image_sha_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compute e2e image tag
|
||||
id: image-tag
|
||||
run: |
|
||||
image_repo="ghcr.io/${{ github.repository_owner }}/kemkas-e2e"
|
||||
sha_tag="${image_repo}:${GITHUB_SHA::8}"
|
||||
|
||||
tags="$sha_tag"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=$'\n'"${image_repo}:latest"
|
||||
fi
|
||||
|
||||
echo "image_sha_tag=${sha_tag}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$tags"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push e2e image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./e2e/
|
||||
file: ./e2e/Dockerfile
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image-tag.outputs.tags }}
|
||||
|
||||
run-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- docker-fe-build
|
||||
- build-fe
|
||||
- build-e2e
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
env:
|
||||
BACKEND_IMAGE: ${{ github.event.inputs.backend_image || 'ghcr.io/morbalint/kemkas-backend:b8565f8e' }}
|
||||
FRONTEND_IMAGE: ${{ needs.build-fe.outputs.image_sha_tag }}
|
||||
E2E_IMAGE: ${{ needs.build-e2e.outputs.image_sha_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/kemkas-deployment
|
||||
ref: refs/heads/main
|
||||
ssh-key: ${{ secrets.DEPLOYMENT_REPO_DEPLOY_KEY }}
|
||||
- uses: mikefarah/yq@v4
|
||||
with:
|
||||
cmd: yq -i '.spec.template.spec.containers[0].image = "${{ needs.docker-fe-build.outputs.image_tag }}"' ./kemkas-beta/kemkas-fe-deployment.yaml
|
||||
- run: |
|
||||
git config --add user.email "beta-fe-deployment-bot@kemkas.hu"
|
||||
git config --add user.name "Deployment Bot Beta FE"
|
||||
git commit -a -m "deploy ${{ needs.docker-fe-build.outputs.image_tag }}"
|
||||
git push
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup docker compose
|
||||
uses: docker/setup-compose-action@v2
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Pull images for test run
|
||||
run: docker compose -f e2e.docker-compose.yaml pull
|
||||
- name: Run Docker Compose E2E tests
|
||||
run: docker compose -f e2e.docker-compose.yaml up --no-build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report
|
||||
if-no-files-found: ignore
|
||||
- name: Upload Playwright test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
- name: Tear down Docker Compose stack
|
||||
if: always()
|
||||
run: docker compose -f e2e.docker-compose.yaml down -v --remove-orphans
|
||||
|
||||
+10
-1
@@ -30,4 +30,13 @@ yarn-error.log*
|
||||
/local
|
||||
|
||||
obj/
|
||||
bin/
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# playwright
|
||||
/e2e/playwright-report/
|
||||
/e2e/test-results/
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
nodejs 20.15.1
|
||||
yarn 1.22.22
|
||||
nodejs 24.14.1
|
||||
yarn 1.22.22
|
||||
|
||||
@@ -25,3 +25,13 @@ dotnet dev-certs https --format PEM --no-password -ep ~/.aspnet/https/kemkas.pem
|
||||
```
|
||||
|
||||
Later start the docker compose first and the project launch settings after the DB is up and running.
|
||||
|
||||
## E2E with Docker Compose
|
||||
|
||||
For a fully containerized end-to-end run (tagged backend image + freshly built frontend/e2e images):
|
||||
|
||||
```shell
|
||||
docker compose -f e2e.docker-compose.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
```
|
||||
|
||||
Use `BACKEND_IMAGE` to select a specific backend tag, and note this compose stack auto-generates TLS certificates for the nginx proxy.
|
||||
|
||||
+18
-2
@@ -1,11 +1,19 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16.1-alpine3.19
|
||||
image: postgres:16.13-alpine3.23
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: "kemkas"
|
||||
POSTGRES_PASSWORD: "kemkas-dev42"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kemkas -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
backend:
|
||||
image: ghcr.io/morbalint/kemkas-backend:b8565f8e
|
||||
@@ -15,6 +23,9 @@ services:
|
||||
ConnectionStrings__DefaultConnection: "Server=db;User Id=kemkas;Password=kemkas-dev42"
|
||||
Email__DomainName: "mail.kemkas.hu"
|
||||
Email__ApiKey: ""
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
proxy:
|
||||
image: nginx:latest
|
||||
@@ -23,4 +34,9 @@ services:
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- $HOME/.aspnet/https/kemkas.pem:/etc/nginx/kemkas.pem
|
||||
- $HOME/.aspnet/https/kemkas.key:/etc/nginx/kemkas.key
|
||||
- $HOME/.aspnet/https/kemkas.key:/etc/nginx/kemkas.key
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
certs:
|
||||
image: alpine:3.21
|
||||
volumes:
|
||||
- tls-certs:/certs
|
||||
- ./scripts/generate-e2e-certs.sh:/scripts/generate-e2e-certs.sh:ro
|
||||
command: ["sh", "/scripts/generate-e2e-certs.sh"]
|
||||
|
||||
db:
|
||||
image: postgres:16.13-alpine3.23
|
||||
environment:
|
||||
POSTGRES_USER: "kemkas"
|
||||
POSTGRES_PASSWORD: "kemkas-dev42"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kemkas -d postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/morbalint/kemkas-backend:b8565f8e}
|
||||
environment:
|
||||
ConnectionStrings__DefaultConnection: "Server=db;User Id=kemkas;Password=kemkas-dev42"
|
||||
Email__DomainName: "mail.kemkas.hu"
|
||||
Email__ApiKey: ""
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/morbalint/kemkas-frontend:local}
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
|
||||
proxy:
|
||||
image: nginx:1.29.7-alpine3.23
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./e2e.nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- tls-certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
certs:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_started
|
||||
frontend:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider --no-check-certificate https://127.0.0.1:8000 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
e2e:
|
||||
image: ${E2E_IMAGE:-ghcr.io/morbalint/kemkas-e2e:local}
|
||||
build:
|
||||
context: ./e2e
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
E2E_BASE_URL: https://proxy:8000
|
||||
E2E_USE_WEBSERVER: "false"
|
||||
E2E_TRUSTED_CERT_PATH: /certs/rootCA.pem
|
||||
E2E_WAIT_FOR_URL: https://proxy:8000
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
- ./e2e/playwright-report:/work/playwright-report
|
||||
- ./e2e/test-results:/work/test-results
|
||||
depends_on:
|
||||
certs:
|
||||
condition: service_completed_successfully
|
||||
proxy:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
tls-certs:
|
||||
@@ -0,0 +1,96 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream frontend {
|
||||
server frontend:80;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server backend:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8000 ssl;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/kemkas.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/kemkas.key;
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
location /Identity/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
|
||||
proxy_set_header X-Forwarded-Proto "$scheme";
|
||||
proxy_set_header X-Forwarded-Host "$http_host";
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
|
||||
proxy_set_header X-Forwarded-Proto "$scheme";
|
||||
proxy_set_header X-Forwarded-Host "$http_host";
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /signin- {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
|
||||
proxy_set_header X-Forwarded-Proto "$scheme";
|
||||
proxy_set_header X-Forwarded-Host "$http_host";
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /lib/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /js/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /css/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /img/ {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
.vscode
|
||||
.git
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
|
||||
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libnss3-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x /work/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/work/docker-entrypoint.sh"]
|
||||
CMD ["yarn", "test"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# E2E tests (Playwright)
|
||||
|
||||
This folder contains browser-level end-to-end tests for public, backend-independent user flows.
|
||||
|
||||
## Current scope
|
||||
|
||||
- Guest redirect from `/` to `/2e/karakter`
|
||||
- Guest header links (`Register`, `Login`)
|
||||
- Public legal pages (`/privacy.html`, `/ogl.html`, `/aelf.html`)
|
||||
|
||||
## Run locally
|
||||
|
||||
From `frontend/`:
|
||||
|
||||
- Install Playwright browser binaries:
|
||||
- `yarn test:e2e:install`
|
||||
- Run tests:
|
||||
- `yarn test:e2e`
|
||||
- Run headed mode:
|
||||
- `yarn test:e2e:headed`
|
||||
- Run interactive UI mode:
|
||||
- `yarn test:e2e:ui`
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests use the config in `../e2e/playwright.config.ts`.
|
||||
- The Playwright web server starts Vite on `http://127.0.0.1:4173`.
|
||||
- Tests mock `GET /api/User/me` so they stay deterministic without the backend.
|
||||
|
||||
## Run with Docker Compose
|
||||
|
||||
This mode runs against a full containerized stack:
|
||||
|
||||
- tagged backend image (`BACKEND_IMAGE`)
|
||||
- supporting services (`postgres`, `nginx` proxy)
|
||||
- freshly built `frontend` image
|
||||
- freshly built `e2e` image
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Docker with Compose
|
||||
|
||||
Run:
|
||||
|
||||
```shell
|
||||
docker compose -f e2e.docker-compose.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
```
|
||||
|
||||
Run with a different backend tag:
|
||||
|
||||
```shell
|
||||
BACKEND_IMAGE=ghcr.io/morbalint/kemkas-backend:<tag> \
|
||||
docker compose -f e2e.docker-compose.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
|
||||
```
|
||||
|
||||
The `certs` service generates a local CA and nginx server certificate in a shared volume.
|
||||
The `e2e` image imports that CA (`/certs/rootCA.pem`) into both the system trust store and Chromium's NSS store before running tests.
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
trusted_cert_path="${E2E_TRUSTED_CERT_PATH:-}"
|
||||
if [[ -n "$trusted_cert_path" ]]; then
|
||||
if [[ ! -f "$trusted_cert_path" ]]; then
|
||||
echo "Trusted certificate not found at: $trusted_cert_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0644 -D "$trusted_cert_path" /usr/local/share/ca-certificates/kemkas-proxy.crt
|
||||
update-ca-certificates
|
||||
|
||||
if command -v certutil >/dev/null 2>&1; then
|
||||
for candidate_home in /root /home/pwuser; do
|
||||
if [[ -d "$candidate_home" ]]; then
|
||||
nss_db_dir="$candidate_home/.pki/nssdb"
|
||||
mkdir -p "$nss_db_dir"
|
||||
certutil -d "sql:$nss_db_dir" -N --empty-password >/dev/null 2>&1 || true
|
||||
certutil -d "sql:$nss_db_dir" -D -n kemkas-proxy >/dev/null 2>&1 || true
|
||||
certutil -d "sql:$nss_db_dir" -A -t "C,," -n kemkas-proxy -i "$trusted_cert_path"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${E2E_WAIT_FOR_URL:-}" ]]; then
|
||||
echo "Waiting for target URL: ${E2E_WAIT_FOR_URL}"
|
||||
for attempt in {1..60}; do
|
||||
if curl --silent --show-error --fail "${E2E_WAIT_FOR_URL}" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$attempt" -eq 60 ]]; then
|
||||
echo "Timed out waiting for ${E2E_WAIT_FOR_URL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "kemkas-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:ui": "playwright test --ui",
|
||||
"install:browsers": "playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const port = Number(process.env.E2E_PORT ?? 4173);
|
||||
const baseURL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
const useWebServer = process.env.E2E_USE_WEBSERVER !== 'false';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.join(__dirname, 'tests'),
|
||||
outputDir: path.join(__dirname, 'test-results'),
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
timeout: 30_000,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: path.join(__dirname, 'playwright-report'), open: 'never' }],
|
||||
],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
...(useWebServer
|
||||
? {
|
||||
webServer: {
|
||||
command: `node ./node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}`,
|
||||
cwd: path.resolve(__dirname, '../frontend'),
|
||||
port,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
async function mockGuestSession(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.route('**/api/User/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
body: '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('guest users are redirected to 2e character creation', async ({ page }) => {
|
||||
await mockGuestSession(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveURL(/\/2e\/karakter$/);
|
||||
await expect(page.getByRole('heading', { name: 'Karakter létrehozása' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Mentés/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /PDF/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('guest navigation shows Register and Login links', async ({ page }) => {
|
||||
await mockGuestSession(page);
|
||||
|
||||
await page.goto('/2e/karakter');
|
||||
|
||||
const registerLink = page.getByRole('link', { name: 'Register' });
|
||||
const loginLink = page.getByRole('link', { name: 'Login' });
|
||||
|
||||
await expect(registerLink).toBeVisible();
|
||||
await expect(registerLink).toHaveAttribute('href', '/Identity/Account/Register');
|
||||
await expect(loginLink).toBeVisible();
|
||||
await expect(loginLink).toHaveAttribute('href', '/Identity/Account/Login');
|
||||
});
|
||||
|
||||
test('public legal pages are served', async ({ page }) => {
|
||||
await page.goto('/privacy.html');
|
||||
await expect(page.getByRole('heading', { name: 'Privacy Policy', level: 1 })).toBeVisible();
|
||||
|
||||
await page.goto('/ogl.html');
|
||||
await expect(page).toHaveTitle(/Open Game License v1\.0a/);
|
||||
await expect(page.locator('body')).toContainText('OPEN GAME LICENSE Version 1.0a');
|
||||
|
||||
await page.goto('/aelf.html');
|
||||
await expect(page).toHaveTitle(/AELF Open License version 1\.0a/);
|
||||
await expect(page.locator('body')).toContainText('AELF OPEN LICENSE VERSION 1.0a');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@playwright/test@1.59.1":
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
|
||||
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
|
||||
dependencies:
|
||||
playwright "1.59.1"
|
||||
|
||||
fsevents@2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
|
||||
playwright-core@1.59.1:
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
|
||||
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
|
||||
|
||||
playwright@1.59.1:
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
|
||||
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
|
||||
dependencies:
|
||||
playwright-core "1.59.1"
|
||||
optionalDependencies:
|
||||
fsevents "2.3.2"
|
||||
@@ -1,3 +1,3 @@
|
||||
PORT=44478
|
||||
HTTPS=true
|
||||
HTTPS=false
|
||||
|
||||
|
||||
+6
-5
@@ -1,17 +1,18 @@
|
||||
FROM node:20.15.1-bookworm as builder
|
||||
FROM node:24.14.1-trixie AS builder
|
||||
|
||||
WORKDIR "/src"
|
||||
|
||||
COPY ["package.json", "yarn.lock", "./"]
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN yarn install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN ./compile-less.sh
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:latest as proxy
|
||||
FROM nginx:1.29.7-alpine3.23 AS proxy
|
||||
|
||||
COPY --from=builder "/src/build" "/usr/share/nginx/html"
|
||||
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
|
||||
|
||||
COPY --from=builder "/src/dist" "/usr/share/nginx/html"
|
||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
+12
-41
@@ -1,46 +1,17 @@
|
||||
# Getting Started with Create React App
|
||||
# Frontend (Vite)
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This frontend now uses Vite + React + TypeScript.
|
||||
|
||||
## Available Scripts
|
||||
## Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
- `yarn dev` or `yarn start`: Start the Vite dev server.
|
||||
- `yarn build`: Build production assets to `dist/`.
|
||||
- `yarn preview`: Serve the production build locally.
|
||||
- `yarn test`: Run tests once with Vitest.
|
||||
- `yarn test:watch`: Run tests in watch mode.
|
||||
|
||||
### `yarn start`
|
||||
## Notes
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
- Static files are served from `public/`.
|
||||
- The app entry HTML is `index.html` at the project root.
|
||||
- LESS files are compiled by Vite automatically, no separate compile step is needed.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Compiling all LESS files to CSS"
|
||||
|
||||
function compile_less() {
|
||||
FROM=$1
|
||||
TO=${FROM/.*/.css}
|
||||
SOURCE_MAP=${FROM/.*/.css.map}
|
||||
echo "$FROM --> $TO & $SOURCE_MAP"
|
||||
node_modules/less/bin/lessc "$FROM" "$TO" --source-map="$SOURCE_MAP"
|
||||
}
|
||||
|
||||
export -f compile_less
|
||||
find src -type f -name '*.less' -exec bash -c 'compile_less $1' shell {} \;
|
||||
@@ -0,0 +1,23 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'build/**', 'node_modules/**'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'vite.config.mts', 'vitest.setup.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-debugger': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Kém Karakter alkotási segédlet" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merienda&display=swap" rel="stylesheet" />
|
||||
<title>Kém Karakter App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+22
-26
@@ -2,6 +2,9 @@
|
||||
"name": "kemkas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/faro-web-sdk": "^1.4.2",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
@@ -9,25 +12,19 @@
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/jest": "^29.5.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.13.5",
|
||||
"bootstrap": "^5.3.2",
|
||||
"downloadjs": "^1.4.7",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"jquery": "^3.6.0",
|
||||
"merge": "^2.1.1",
|
||||
"oidc-client": "^1.11.5",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.17.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"typescript": "^5.2.2",
|
||||
"workbox-background-sync": "^7.1.0",
|
||||
"workbox-broadcast-update": "^7.1.0",
|
||||
@@ -43,23 +40,27 @@
|
||||
"workbox-streams": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@types/downloadjs": "^1.4.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.24.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"less": "^4.2.0",
|
||||
"less-loader": "^11.1.3"
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rimraf ./build && react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "cross-env CI=true react-scripts test --env=jsdom",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\" vite.config.mts vitest.setup.ts",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "yarn --cwd ../e2e test",
|
||||
"test:e2e:headed": "yarn --cwd ../e2e test:headed",
|
||||
"test:e2e:ui": "yarn --cwd ../e2e test:ui",
|
||||
"test:e2e:install": "yarn --cwd ../e2e install:browsers"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -72,10 +73,5 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleNameMapper": {
|
||||
"^axios$": "axios/dist/node/axios.cjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Kém Karakter alkotási segédlet"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merienda&display=swap" rel="stylesheet">
|
||||
<title>Kém Karakter App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,23 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import App from './App';
|
||||
|
||||
test('renders App', () => {
|
||||
render(<App faro={undefined}/>);
|
||||
vi.mock('./shared/api/http', () => ({
|
||||
getText: vi.fn(async () => ''),
|
||||
getJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createBrowserRouter: vi.fn(() => ({})),
|
||||
RouterProvider: () => <div data-testid="router-provider" />,
|
||||
};
|
||||
});
|
||||
|
||||
test('renders App', () => {
|
||||
render(<App />);
|
||||
});
|
||||
|
||||
+125
-92
@@ -1,115 +1,148 @@
|
||||
import * as React from "react";
|
||||
import "./App.css";
|
||||
import {Faro} from "@grafana/faro-web-sdk";
|
||||
import {createBrowserRouter, redirect, RouterProvider} from "react-router-dom";
|
||||
import CreateCharacter from "./first-edition/pages/CreateCharacter";
|
||||
import CharacterList from "./shared/pages/CharacterList";
|
||||
import {createBrowserRouter, LoaderFunctionArgs, Navigate, Outlet, redirect, RouterProvider} from "react-router-dom";
|
||||
import CharacterList, {CharacterListItemDto} from "./shared/pages/CharacterList";
|
||||
import ErrorBoundary from "./shared/ErrorBoundary";
|
||||
import Header from "./shared/Header";
|
||||
import Footer from "./shared/Footer";
|
||||
import CreateCharacter2E from "./second-edition/pages/CreateCharacter2E";
|
||||
import store, {AppDispatch, RootState} from './store'
|
||||
import { Provider } from "react-redux"
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import {load, setUser, unsetUser, userSelector} from './shared/domain-models/userSlice'
|
||||
import {fetchCurrentUser, userSelector} from './shared/domain-models/userSlice'
|
||||
import {setCharacter} from "./second-edition/domain-models/characterSlice";
|
||||
import {getJson} from "./shared/api/http";
|
||||
import {KarakterInputs} from "./first-edition/domain-models/karakter";
|
||||
import {Karakter2E} from "./second-edition/domain-models/karakter2E";
|
||||
const CreateCharacter = React.lazy(() => import("./first-edition/pages/CreateCharacter"))
|
||||
const CreateCharacter2E = React.lazy(() => import("./second-edition/pages/CreateCharacter2E"))
|
||||
|
||||
function Router(props: {faro?: Faro}) {
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()()
|
||||
const fetchedUser = useSelector.withTypes<RootState>()(userSelector)
|
||||
|
||||
if (fetchedUser.state === "not-started") {
|
||||
dispatch(load())
|
||||
fetch(`${window.location.origin}/api/User/me`)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.text();
|
||||
}
|
||||
console.log(response)
|
||||
})
|
||||
.then(userNameResponse => {
|
||||
if (userNameResponse && userNameResponse.length > 0) {
|
||||
dispatch(setUser(userNameResponse))
|
||||
} else {
|
||||
dispatch(unsetUser())
|
||||
}
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(unsetUser())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
loader: () => fetchedUser.email == null ? redirect("/2e/karakter") : redirect("/karaktereim")
|
||||
},
|
||||
{
|
||||
path: "/karaktereim",
|
||||
element: <CharacterList />,
|
||||
loader: () => fetch(`${window.location.origin}/api/Character`),
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/1e",
|
||||
loader: () => redirect("/1e/karakter")
|
||||
},
|
||||
{
|
||||
path: "/1e/karakter",
|
||||
element: <CreateCharacter faro={props.faro} />,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/1e/karakter/:id",
|
||||
element: <CreateCharacter faro={props.faro}/>,
|
||||
loader: args => fetch(`${window.location.origin}/api/Character1E/${args.params.id}`),
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/2e",
|
||||
loader: () => redirect("/2e/karakter")
|
||||
},
|
||||
{
|
||||
path: "/2e/karakter",
|
||||
element: <CreateCharacter2E faro={props.faro}/>,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "/2e/karakter/:id",
|
||||
element: <CreateCharacter2E faro={props.faro}/>,
|
||||
loader: async args => {
|
||||
const response = await fetch(`${window.location.origin}/api/Character2E/${args.params.id}`)
|
||||
if (response.ok) {
|
||||
const loaded2Echaracter = await response.json()
|
||||
dispatch(setCharacter(loaded2Echaracter))
|
||||
return loaded2Echaracter;
|
||||
}
|
||||
return {
|
||||
isPublic: false,
|
||||
error: response.statusText,
|
||||
};
|
||||
},
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
]);
|
||||
const FaroContext = React.createContext<Faro | undefined>(undefined)
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className='container'>
|
||||
<RouterProvider router={router}/>
|
||||
</div>
|
||||
<>
|
||||
<Header />
|
||||
<div className='container'>
|
||||
<Outlet />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IndexRedirect() {
|
||||
const fetchedUser = useSelector.withTypes<RootState>()(userSelector)
|
||||
|
||||
if (fetchedUser.state !== "finished") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate replace to={fetchedUser.email == null ? "/2e/karakter" : "/karaktereim"} />
|
||||
}
|
||||
|
||||
function CreateCharacter1ERoute() {
|
||||
const faro = React.useContext(FaroContext)
|
||||
return (
|
||||
<React.Suspense fallback={<div className="p-3">Betoltes...</div>}>
|
||||
{faro ? <CreateCharacter faro={faro} /> : <CreateCharacter />}
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateCharacter2ERoute() {
|
||||
const faro = React.useContext(FaroContext)
|
||||
return (
|
||||
<React.Suspense fallback={<div className="p-3">Betoltes...</div>}>
|
||||
{faro ? <CreateCharacter2E faro={faro} /> : <CreateCharacter2E />}
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function UserBootstrap() {
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()()
|
||||
|
||||
React.useEffect(() => {
|
||||
void dispatch(fetchCurrentUser())
|
||||
}, [dispatch])
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const loadCharacterList = () => getJson<CharacterListItemDto[]>("/api/Character")
|
||||
|
||||
const loadCharacter1E = ({params}: LoaderFunctionArgs) => getJson<KarakterInputs & { isPublic: boolean }>(`/api/Character1E/${params.id}`)
|
||||
|
||||
const loadCharacter2E = async ({params}: LoaderFunctionArgs) => {
|
||||
try {
|
||||
const loaded2Echaracter = await getJson<Karakter2E & { isPublic: boolean }>(`/api/Character2E/${params.id}`)
|
||||
store.dispatch(setCharacter(loaded2Echaracter))
|
||||
return loaded2Echaracter;
|
||||
} catch (error) {
|
||||
return {
|
||||
isPublic: false,
|
||||
error: error instanceof Error ? error.message : "Failed to load character",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <IndexRedirect />,
|
||||
},
|
||||
{
|
||||
path: "karaktereim",
|
||||
element: <CharacterList />,
|
||||
loader: loadCharacterList,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "1e",
|
||||
loader: () => redirect("/1e/karakter")
|
||||
},
|
||||
{
|
||||
path: "1e/karakter",
|
||||
element: <CreateCharacter1ERoute />,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "1e/karakter/:id",
|
||||
element: <CreateCharacter1ERoute />,
|
||||
loader: loadCharacter1E,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "2e",
|
||||
loader: () => redirect("/2e/karakter")
|
||||
},
|
||||
{
|
||||
path: "2e/karakter",
|
||||
element: <CreateCharacter2ERoute />,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
{
|
||||
path: "2e/karakter/:id",
|
||||
element: <CreateCharacter2ERoute />,
|
||||
loader: loadCharacter2E,
|
||||
ErrorBoundary: ErrorBoundary,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function App(props: { faro?: Faro }) {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Header/>
|
||||
<Router faro={props.faro} />
|
||||
<Footer />
|
||||
<FaroContext.Provider value={props.faro}>
|
||||
<UserBootstrap />
|
||||
<RouterProvider router={router}/>
|
||||
</FaroContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import {KarakterInputs} from "../domain-models/karakter";
|
||||
import axios from "axios";
|
||||
import {postJson} from "../../shared/api/http";
|
||||
|
||||
export async function StoreNewCharacter(karakter: KarakterInputs, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character1E/`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<KarakterInputs, string>("/api/Character1E/", karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export async function UpdateCharacter(id: string, karakter: KarakterInputs, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character1E/${id}`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<KarakterInputs, string>(`/api/Character1E/${id}`, karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useState} from 'react';
|
||||
import './CreateCharacter.css'
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import './CreateCharacter.less'
|
||||
import {Faj, TulajdonsagokFajjal} from "../domain-models/faj";
|
||||
import {Osztaly, SetFelszerelesForChangedOsztaly} from "../domain-models/osztaly"
|
||||
import FajSelector from "../components/FajSelector";
|
||||
@@ -56,15 +56,43 @@ function CreateCharacterPage(props: {
|
||||
|
||||
let [karakter, changeKarakter] = useState(initialKarakterInputs)
|
||||
|
||||
const tulajdonsagokFajjal = TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj)
|
||||
const tulajdonsagokFajjal = useMemo(() => TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj), [karakter.tulajdonsagok, karakter.faj])
|
||||
|
||||
const changeKepzettseg = (k: KepzettsegId[]) => changeKarakter({...karakter, kepzettsegek: k})
|
||||
const changeTolvajKepzettseg = (tk?: KepzettsegId[]) => changeKarakter({...karakter, tolvajKepzettsegek: tk})
|
||||
SetDefaultTolvajKepzettsegek(karakter, changeTolvajKepzettseg)
|
||||
SetDefaultKepzettsegek({...karakter, tulajdonsagok: tulajdonsagokFajjal}, changeKepzettseg)
|
||||
const availableKepzettsegList = AvailableKezpettsegList(karakter.osztaly)
|
||||
const setFelszereles = (f: KarakterFelszereles) => changeKarakter({...karakter, felszereles: f})
|
||||
SetFelszerelesForChangedOsztaly(karakter.osztaly, karakter.felszereles, setFelszereles)
|
||||
const changeKepzettseg = useCallback((k: KepzettsegId[]) => {
|
||||
changeKarakter(prev => ({...prev, kepzettsegek: k}))
|
||||
}, [])
|
||||
const changeTolvajKepzettseg = useCallback((tk?: KepzettsegId[]) => {
|
||||
changeKarakter(prev => ({...prev, tolvajKepzettsegek: tk}))
|
||||
}, [])
|
||||
const availableKepzettsegList = useMemo(() => AvailableKezpettsegList(karakter.osztaly), [karakter.osztaly])
|
||||
const setFelszereles = useCallback((f: KarakterFelszereles) => {
|
||||
changeKarakter(prev => ({...prev, felszereles: f}))
|
||||
}, [])
|
||||
|
||||
const tolvajDefaultsInput = useMemo(() => ({
|
||||
osztaly: karakter.osztaly,
|
||||
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
|
||||
}), [karakter.osztaly, karakter.tolvajKepzettsegek])
|
||||
|
||||
const kepzettsegDefaultsInput = useMemo(() => ({
|
||||
osztaly: karakter.osztaly,
|
||||
faj: karakter.faj,
|
||||
tulajdonsagok: tulajdonsagokFajjal,
|
||||
kepzettsegek: karakter.kepzettsegek,
|
||||
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
|
||||
}), [karakter.osztaly, karakter.faj, tulajdonsagokFajjal, karakter.kepzettsegek, karakter.tolvajKepzettsegek])
|
||||
|
||||
useEffect(() => {
|
||||
SetDefaultTolvajKepzettsegek(tolvajDefaultsInput, changeTolvajKepzettseg)
|
||||
}, [tolvajDefaultsInput, changeTolvajKepzettseg])
|
||||
|
||||
useEffect(() => {
|
||||
SetDefaultKepzettsegek(kepzettsegDefaultsInput, changeKepzettseg)
|
||||
}, [kepzettsegDefaultsInput, changeKepzettseg])
|
||||
|
||||
useEffect(() => {
|
||||
SetFelszerelesForChangedOsztaly(karakter.osztaly, karakter.felszereles, setFelszereles)
|
||||
}, [karakter.osztaly, karakter.felszereles, setFelszereles])
|
||||
|
||||
const levelUp = () => LevelUp(karakter, changeKarakter)
|
||||
const levelDown = () => LevelDown(karakter, changeKarakter)
|
||||
|
||||
Vendored
+2
-1
@@ -1 +1,2 @@
|
||||
/// <reference types="react-scripts" />
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest/globals" />
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import axios from "axios";
|
||||
import {postJson} from "../../shared/api/http";
|
||||
import {Karakter2E} from "../domain-models/karakter2E";
|
||||
|
||||
export async function StoreNewCharacter2E(karakter: Karakter2E, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character2E/`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<Karakter2E, string>("/api/Character2E/", karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
export async function UpdateCharacter2E(id: string, karakter: Karakter2E, isPublic: boolean = false) {
|
||||
let response = await axios.post(`${window.location.origin}/api/Character2E/${id}`, karakter, {
|
||||
withCredentials: true,
|
||||
return postJson<Karakter2E, string>(`/api/Character2E/${id}`, karakter, {
|
||||
params: {
|
||||
isPublic,
|
||||
}
|
||||
})
|
||||
if (response.status < 300){
|
||||
return response.data as string
|
||||
}
|
||||
else {
|
||||
throw Error(response.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,21 +8,23 @@ import {AppDispatch, RootState} from "../../store";
|
||||
import {characterSelector, setHarcosSpecialization} from "../domain-models/characterSlice";
|
||||
|
||||
function HarcosFegyverSpecializacio(props: {
|
||||
szint: number
|
||||
harcosSzint: number,
|
||||
karakterSzint: number,
|
||||
}) {
|
||||
const {
|
||||
szint
|
||||
harcosSzint,
|
||||
karakterSzint,
|
||||
} = props
|
||||
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()()
|
||||
const character = useSelector.withTypes<RootState>()(characterSelector)
|
||||
const specialization = character.szintlepesek[szint-1].harcosFegyver || 'kard_hosszu'
|
||||
const specialization = character.szintlepesek[karakterSzint-1]?.harcosFegyver || 'kard_hosszu'
|
||||
const existingSpecializations = character.szintlepesek.filter(x => x.harcosFegyver != null).map(x => x.harcosFegyver! as string)
|
||||
const changeSpecialization = (specialization: string) => dispatch(setHarcosSpecialization({szint, fegyver: specialization}))
|
||||
const changeSpecialization = (specialization: string) => dispatch(setHarcosSpecialization({szint: karakterSzint, fegyver: specialization}))
|
||||
|
||||
const fegyver = GetFegyver(specialization)
|
||||
const availableFegyverek = AllowedFegyver(Osztaly2E.Harcos)
|
||||
const pickableFegyverek = availableFegyverek.filter(f => f.Id === specialization || existingSpecializations.filter(x => x === f.Id).length < (szint < 9 ? 1 : 2))
|
||||
const pickableFegyverek = availableFegyverek.filter(f => f.Id === specialization || existingSpecializations.filter(x => x === f.Id).length < (harcosSzint < 9 ? 1 : 2))
|
||||
|
||||
|
||||
return <>
|
||||
@@ -32,7 +34,7 @@ function HarcosFegyverSpecializacio(props: {
|
||||
fegyverek={pickableFegyverek}
|
||||
selectedId={specialization}
|
||||
onChange={changeSpecialization}
|
||||
dataTestId={"harcos-specialization-" + szint}
|
||||
dataTestId={"harcos-specialization-" + karakterSzint}
|
||||
/>
|
||||
{fegyver == null && <span className='form-field-error'>Ismeretlen fegyver: {specialization}</span>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import {render} from "@testing-library/react";
|
||||
import {configureStore} from "@reduxjs/toolkit";
|
||||
import {Provider} from "react-redux";
|
||||
import KarakterKepzettsegek from "./Kepzettsegek2E";
|
||||
import userReducer from "../../shared/domain-models/userSlice";
|
||||
import characterReducer, {setCharacter} from "../domain-models/characterSlice";
|
||||
import {DefaultKarakter, Karakter2E} from "../domain-models/karakter2E";
|
||||
import {Osztaly2E} from "../domain-models/osztaly2E";
|
||||
import {Tulajdonsag2E} from "../domain-models/tulajdonsag2E";
|
||||
|
||||
function createStore() {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
character2E: characterReducer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("KarakterKepzettsegek", () => {
|
||||
it("shows one more kepzettseg selector when INT modifier increases from level-up", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 4,
|
||||
tulajdonsagok: {
|
||||
...DefaultKarakter.tulajdonsagok,
|
||||
t_int: 17,
|
||||
},
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 8, tulajdonsagNoveles: Tulajdonsag2E.Intelligencia },
|
||||
],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const store = createStore();
|
||||
store.dispatch(setCharacter(karakter));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<KarakterKepzettsegek />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const selectors = sut.container.querySelectorAll('[data-testid^="kepzettseg-"]');
|
||||
expect(selectors).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("shows one more kepzettseg selector when INT modifier increases at level 8", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 8,
|
||||
tulajdonsagok: {
|
||||
...DefaultKarakter.tulajdonsagok,
|
||||
t_int: 14,
|
||||
},
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 8, tulajdonsagNoveles: Tulajdonsag2E.Ero },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 9 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 6, tulajdonsagNoveles: Tulajdonsag2E.Intelligencia },
|
||||
],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const store = createStore();
|
||||
store.dispatch(setCharacter(karakter));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<KarakterKepzettsegek />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const selectors = sut.container.querySelectorAll('[data-testid^="kepzettseg-"]');
|
||||
expect(selectors).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("shows one more kepzettseg selector when INT modifier increases at level 12", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 12,
|
||||
tulajdonsagok: {
|
||||
...DefaultKarakter.tulajdonsagok,
|
||||
t_int: 17,
|
||||
},
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 8, tulajdonsagNoveles: Tulajdonsag2E.Ero },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 9 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 6, tulajdonsagNoveles: Tulajdonsag2E.Egeszseg },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 4, tulajdonsagNoveles: Tulajdonsag2E.Intelligencia },
|
||||
],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const store = createStore();
|
||||
store.dispatch(setCharacter(karakter));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<KarakterKepzettsegek />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const selectors = sut.container.querySelectorAll('[data-testid^="kepzettseg-"]');
|
||||
expect(selectors).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {arraySetN} from "../../util";
|
||||
import {useDispatch, useSelector} from "react-redux";
|
||||
import {AppDispatch, RootState} from "../../store";
|
||||
import {characterSelector, setKepzettsegek, setTolvajKepzettsegek} from "../domain-models/characterSlice";
|
||||
import {TulajdonsagokFajjal} from "../domain-models/faj2E";
|
||||
import {TulajdonsagokTotal} from "../domain-models/tulajdonsag2E";
|
||||
|
||||
function InternalKepzettsegekSelector(props: {
|
||||
title: string,
|
||||
@@ -70,8 +70,8 @@ function KarakterKepzettsegek () {
|
||||
const karakter = useSelector.withTypes<RootState>()(characterSelector);
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()();
|
||||
|
||||
const tulajdonsagokFajjal = TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj)
|
||||
const numberOfKepzettsegek = GetNumberOfKepzettsegek(tulajdonsagokFajjal.t_int, karakter.faj)
|
||||
const tulajdonsagokTotal = TulajdonsagokTotal(karakter)
|
||||
const numberOfKepzettsegek = GetNumberOfKepzettsegek(tulajdonsagokTotal.t_int, karakter.faj)
|
||||
const kepzettsegek = karakter.kepzettsegek
|
||||
const fajError = karakter.kepzettsegFajError
|
||||
const osztalyError = karakter.kepzettsegOsztalyError
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import React from "react";
|
||||
import {fireEvent, render} from "@testing-library/react";
|
||||
import {configureStore} from "@reduxjs/toolkit";
|
||||
import {Provider} from "react-redux";
|
||||
import LevelUps from "./LevelUps";
|
||||
import userReducer from "../../shared/domain-models/userSlice";
|
||||
import characterReducer, {setCharacter} from "../domain-models/characterSlice";
|
||||
import {AllowedFegyver} from "../domain-models/allowed-fegyver";
|
||||
import {Osztaly2E} from "../domain-models/osztaly2E";
|
||||
import {ChangeOsztalyAtSzint, DefaultKarakter, Karakter2E} from "../domain-models/karakter2E";
|
||||
import {LevelUp} from "../domain-models/szintlepes";
|
||||
import {GetAvailableKepzettsegek} from "../domain-models/kepzettsegek2E";
|
||||
|
||||
function applyLevelUp(karakter: Karakter2E): Karakter2E {
|
||||
let next: Karakter2E | undefined;
|
||||
LevelUp(karakter, updated => {
|
||||
next = updated;
|
||||
});
|
||||
expect(next).toBeDefined();
|
||||
return next as Karakter2E;
|
||||
}
|
||||
|
||||
describe("LevelUps Harcos specialization indexing", () => {
|
||||
it("updates specialization on the selected character level for multiclass", async () => {
|
||||
const level1Tolvaj: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 1,
|
||||
szintlepesek: [{
|
||||
osztaly: Osztaly2E.Tolvaj,
|
||||
HProll: 6,
|
||||
}],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const level2Auto = applyLevelUp(level1Tolvaj);
|
||||
const karakter = ChangeOsztalyAtSzint(level2Auto, Osztaly2E.Harcos, 2);
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
character2E: characterReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setCharacter(karakter));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<LevelUps
|
||||
karakter={store.getState().character2E}
|
||||
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const selector = await sut.findByTestId("harcos-specialization-2");
|
||||
|
||||
const currentSpec = karakter.szintlepesek[1]?.harcosFegyver;
|
||||
const nextSpec = AllowedFegyver(Osztaly2E.Harcos)
|
||||
.map(x => x.Id)
|
||||
.find(id => id !== currentSpec);
|
||||
|
||||
expect(nextSpec).toBeDefined();
|
||||
fireEvent.change(selector, {target: {value: nextSpec as string}});
|
||||
|
||||
const stateAfter = store.getState().character2E;
|
||||
expect(stateAfter.szintlepesek[0]?.harcosFegyver).toBeUndefined();
|
||||
expect(stateAfter.szintlepesek[1]?.harcosFegyver).toBe(nextSpec);
|
||||
});
|
||||
|
||||
it("does not render tulajdonsag selector at level 4 when all abilities are maxed", () => {
|
||||
const maxedCharacter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 1,
|
||||
tulajdonsagok: {
|
||||
t_ero: 18,
|
||||
t_ugy: 18,
|
||||
t_egs: 18,
|
||||
t_int: 18,
|
||||
t_bol: 18,
|
||||
t_kar: 18,
|
||||
},
|
||||
szintlepesek: [{
|
||||
osztaly: Osztaly2E.Pap,
|
||||
HProll: 8,
|
||||
}],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const level2 = applyLevelUp(maxedCharacter);
|
||||
const level3 = applyLevelUp(level2);
|
||||
const level4 = applyLevelUp(level3);
|
||||
|
||||
expect(level4.szintlepesek[3]?.tulajdonsagNoveles).toBeUndefined();
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
character2E: characterReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setCharacter(level4));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<LevelUps
|
||||
karakter={store.getState().character2E}
|
||||
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(sut.queryByTestId("tulajdonsag-4")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render kaloz krit selector when tengeresz milestone exists but krit is missing", () => {
|
||||
const inconsistentTengeresz: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 3,
|
||||
szintlepesek: [
|
||||
{
|
||||
osztaly: Osztaly2E.Tengeresz,
|
||||
HProll: 10,
|
||||
},
|
||||
{
|
||||
osztaly: Osztaly2E.Tengeresz,
|
||||
HProll: 5,
|
||||
},
|
||||
{
|
||||
osztaly: Osztaly2E.Tengeresz,
|
||||
HProll: 7,
|
||||
}
|
||||
],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
character2E: characterReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setCharacter(inconsistentTengeresz));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<LevelUps
|
||||
karakter={store.getState().character2E}
|
||||
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(sut.queryByTestId("kaloz-krit-3")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows and updates Tolvaj extra skill on multiclass Tolvaj level 5", async () => {
|
||||
const multiclassTolvaj: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 6,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3, tolvajExtraKepzettseg: "k_meregkeveres" },
|
||||
],
|
||||
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
character2E: characterReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setCharacter(multiclassTolvaj));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<LevelUps
|
||||
karakter={store.getState().character2E}
|
||||
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const selector = await sut.findByTestId("tolvaj-extra-kepzettseg-6");
|
||||
const nextKepzettseg = GetAvailableKepzettsegek(multiclassTolvaj)
|
||||
.map(x => x.Id)
|
||||
.find(id => id !== multiclassTolvaj.szintlepesek[5]?.tolvajExtraKepzettseg);
|
||||
|
||||
expect(nextKepzettseg).toBeDefined();
|
||||
fireEvent.change(selector, {target: {value: nextKepzettseg as string}});
|
||||
|
||||
const stateAfter = store.getState().character2E;
|
||||
expect(stateAfter.szintlepesek[5]?.tolvajExtraKepzettseg).toBe(nextKepzettseg);
|
||||
expect(stateAfter.szintlepesek[4]?.tolvajExtraKepzettseg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("at Tolvaj level 9 selector includes class-restricted non-Tolvaj skills", async () => {
|
||||
const tolvajLevel9: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 9,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 2, tolvajExtraKepzettseg: "k_meregkeveres" },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, tolvajExtraKepzettseg: "k_alkimia" },
|
||||
],
|
||||
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
character2E: characterReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setCharacter(tolvajLevel9));
|
||||
|
||||
const sut = render(
|
||||
<Provider store={store}>
|
||||
<LevelUps
|
||||
karakter={store.getState().character2E}
|
||||
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const selector = await sut.findByTestId("tolvaj-extra-kepzettseg-9");
|
||||
const arcaneOption = selector.querySelector('option[value="k_csillagjoslas"]');
|
||||
expect(arcaneOption).not.toBeNull();
|
||||
|
||||
fireEvent.change(selector, {target: {value: "k_csillagjoslas"}});
|
||||
expect(store.getState().character2E.szintlepesek[8]?.tolvajExtraKepzettseg).toBe("k_csillagjoslas");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ function LevelUp(props: {
|
||||
const kalozSzint = classLevels[Osztaly2E.Tengeresz]
|
||||
const tolvajSzint = classLevels[Osztaly2E.Tolvaj]
|
||||
const szintlepes = karakter.szintlepesek[szint-1];
|
||||
const tulajdonsagNoveles = szintlepes?.tulajdonsagNoveles;
|
||||
const kalozKritikus = szintlepes?.kalozKritikus;
|
||||
|
||||
return <div>
|
||||
<div className='row mt-3'>
|
||||
@@ -54,10 +56,10 @@ function LevelUp(props: {
|
||||
osztalySzint={classLevels[osztaly]}
|
||||
dataTestId={`osztaly-${szint}`}
|
||||
/>}
|
||||
{szint % 4 === 0 &&
|
||||
{szint % 4 === 0 && tulajdonsagNoveles != null &&
|
||||
<TulajdonsagNoveles
|
||||
tulajdonsagok={karakter.tulajdonsagok}
|
||||
tulajdonsagNoveles={karakter.szintlepesek[szint-1].tulajdonsagNoveles!}
|
||||
tulajdonsagNoveles={tulajdonsagNoveles}
|
||||
changeTulajdonsagNoveles={changeTulajdonsagNoveles}
|
||||
dataTestId={`tulajdonsag-${szint}`}
|
||||
/>}
|
||||
@@ -69,12 +71,13 @@ function LevelUp(props: {
|
||||
/>
|
||||
{osztaly === Osztaly2E.Harcos && harcosSzint % 2 === 1 &&
|
||||
<HarcosFegyverSpecializacio
|
||||
szint={harcosSzint}
|
||||
harcosSzint={harcosSzint}
|
||||
karakterSzint={szint}
|
||||
/>
|
||||
}
|
||||
{osztaly === Osztaly2E.Tengeresz && kalozSzint % 3 === 0 &&
|
||||
{osztaly === Osztaly2E.Tengeresz && kalozSzint % 3 === 0 && kalozKritikus != null &&
|
||||
<KalozKritikus
|
||||
kritFegyverId={szintlepes.kalozKritikus!}
|
||||
kritFegyverId={kalozKritikus}
|
||||
existingKrits={karakter.szintlepesek.map(x => x.kalozKritikus)}
|
||||
changeKrit={changeKalozKritikus}
|
||||
szint={kalozSzint}
|
||||
@@ -82,12 +85,17 @@ function LevelUp(props: {
|
||||
}
|
||||
{osztaly === Osztaly2E.Tolvaj
|
||||
&& (tolvajSzint === 5 || tolvajSzint === 9)
|
||||
&& szintlepes.tolvajExtraKepzettseg != null &&
|
||||
&& szintlepes?.tolvajExtraKepzettseg != null &&
|
||||
<div className='row m-2'>
|
||||
<label className='col-md-2 col-sm-3 col-form-label'>Extra képzettség</label>
|
||||
<KepzettsegSelector
|
||||
selected={Kepzettsegek[szintlepes.tolvajExtraKepzettseg]}
|
||||
kepzettsegek={[Kepzettsegek[szintlepes.tolvajExtraKepzettseg], ...GetAvailableKepzettsegek(karakter)]}
|
||||
selected={Kepzettsegek[szintlepes!.tolvajExtraKepzettseg]}
|
||||
kepzettsegek={[
|
||||
Kepzettsegek[szintlepes!.tolvajExtraKepzettseg],
|
||||
...GetAvailableKepzettsegek(karakter, {
|
||||
ignoreClassRestrictions: tolvajSzint === 9,
|
||||
})
|
||||
]}
|
||||
dataTestId={`tolvaj-extra-kepzettseg-${szint}`}
|
||||
changeKepzettseg={changeExtraTolvajKepzettseg}
|
||||
/>
|
||||
@@ -100,16 +108,19 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
|
||||
const {karakter, changeKarakter} = props;
|
||||
|
||||
const tulajdonsagokAtSzint = (szint: number) => TulajdonsagokTotal({...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint)})
|
||||
const szintlepesAt = (szint: number) => karakter.szintlepesek[szint-1]!
|
||||
|
||||
const changeRolledHPAtSzint = (szint: number) => (newHProll: number) => {
|
||||
changeKarakter({...karakter, szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {...karakter.szintlepesek[szint-1], HProll: newHProll})})
|
||||
const szintlepes = szintlepesAt(szint)
|
||||
changeKarakter({...karakter, szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {...szintlepes, HProll: newHProll})})
|
||||
console.log(karakter)
|
||||
}
|
||||
const changeTulajdonsagNovelesAtSzint = (szint: number) => {
|
||||
return (t: Tulajdonsag2E) => {
|
||||
const szintlepes = szintlepesAt(szint)
|
||||
changeKarakter({...karakter,
|
||||
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
|
||||
...karakter.szintlepesek[szint-1],
|
||||
...szintlepes,
|
||||
tulajdonsagNoveles: t
|
||||
})
|
||||
})
|
||||
@@ -118,10 +129,11 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
|
||||
|
||||
const changeKalozKritikusAtSzint = (szint: number) => {
|
||||
return (krit: string) => {
|
||||
const szintlepes = szintlepesAt(szint)
|
||||
changeKarakter({
|
||||
...karakter,
|
||||
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
|
||||
...karakter.szintlepesek[szint-1],
|
||||
...szintlepes,
|
||||
kalozKritikus: krit
|
||||
})
|
||||
})
|
||||
@@ -130,10 +142,11 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
|
||||
|
||||
const changeExtraTolvajKepzettseg = (szint: number) => {
|
||||
return (k: KepzettsegId) => {
|
||||
const szintlepes = szintlepesAt(szint)
|
||||
changeKarakter({
|
||||
...karakter,
|
||||
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
|
||||
...karakter.szintlepesek[szint-1],
|
||||
...szintlepes,
|
||||
tolvajExtraKepzettseg: k,
|
||||
})
|
||||
})
|
||||
@@ -144,10 +157,13 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
|
||||
|
||||
return <>
|
||||
{levels.slice(1).map(szint =>
|
||||
{
|
||||
const szintlepes = szintlepesAt(szint)
|
||||
return (
|
||||
<LevelUp
|
||||
key={`level-up-${szint}`}
|
||||
szint={szint}
|
||||
osztaly={karakter.szintlepesek[szint-1].osztaly}
|
||||
osztaly={szintlepes.osztaly}
|
||||
karakter={{...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint) ,tulajdonsagok: tulajdonsagokAtSzint(szint)}}
|
||||
changeTulajdonsagNoveles={changeTulajdonsagNovelesAtSzint(szint)}
|
||||
changeRolledHP={changeRolledHPAtSzint(szint)}
|
||||
@@ -155,6 +171,8 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
|
||||
changeOsztaly={(o) => changeKarakter(ChangeOsztalyAtSzint(karakter, o, szint))}
|
||||
changeExtraTolvajKepzettseg={changeExtraTolvajKepzettseg(szint)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {AllowedFegyver} from "./allowed-fegyver";
|
||||
import {BaseHP} from "./masodlagos_ertekek";
|
||||
import {dAny} from "../../shared/domain-models/kockak";
|
||||
import {KarakterVarazslat} from "./varazslat";
|
||||
import {GetAvailableKepzettsegek} from "./kepzettsegek2E";
|
||||
|
||||
export interface Karakter2E {
|
||||
id?: string,
|
||||
@@ -51,13 +52,15 @@ export const DefaultKarakter: Karakter2E = {
|
||||
varazslatok: [],
|
||||
}
|
||||
|
||||
function setOsztalyForSzintLepes(szintlepesek: Szintlepes[], osztaly: Osztaly2E, szint: number): Szintlepes[] {
|
||||
function setOsztalyForSzintLepes(karakter: Karakter2E, osztaly: Osztaly2E, szint: number): Szintlepes[] {
|
||||
const szintlepesek = karakter.szintlepesek
|
||||
const prev = szintlepesek.slice(0, szint-1)
|
||||
const current : Szintlepes = {
|
||||
...szintlepesek[szint-1],
|
||||
osztaly,
|
||||
harcosFegyver: undefined,
|
||||
kalozKritikus: undefined
|
||||
kalozKritikus: undefined,
|
||||
tolvajExtraKepzettseg: undefined,
|
||||
}
|
||||
const tail = szintlepesek.slice(szint).map(x => {return {...x}})
|
||||
|
||||
@@ -117,6 +120,39 @@ function setOsztalyForSzintLepes(szintlepesek: Szintlepes[], osztaly: Osztaly2E,
|
||||
tail[i].kalozKritikus = kalozKritAtSzint(kalozSzint)
|
||||
}
|
||||
}
|
||||
|
||||
let tolvajSzint = prev.filter(x => x.osztaly === Osztaly2E.Tolvaj).length
|
||||
const existingTolvajExtraKepzettsegek = szintlepesek
|
||||
.map(x => x.tolvajExtraKepzettseg)
|
||||
.filter(x => x != null) as KepzettsegId[]
|
||||
const tolvajExtraKepzettsegAtSzint = (tSzint: number, atLevelSzintlepesek: Szintlepes[]): (KepzettsegId | undefined) => {
|
||||
if (tSzint !== 5 && tSzint !== 9) {
|
||||
return undefined
|
||||
}
|
||||
const extraIndex = tSzint === 5 ? 0 : 1
|
||||
if (existingTolvajExtraKepzettsegek.length > extraIndex) {
|
||||
return existingTolvajExtraKepzettsegek[extraIndex]
|
||||
}
|
||||
return GetAvailableKepzettsegek({
|
||||
szintlepesek: atLevelSzintlepesek,
|
||||
faj: karakter.faj,
|
||||
kepzettsegek: karakter.kepzettsegek,
|
||||
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
|
||||
}, {
|
||||
ignoreClassRestrictions: tSzint === 9,
|
||||
})[0]?.Id
|
||||
}
|
||||
|
||||
if (osztaly === Osztaly2E.Tolvaj) {
|
||||
tolvajSzint++
|
||||
current.tolvajExtraKepzettseg = tolvajExtraKepzettsegAtSzint(tolvajSzint, [...prev, current])
|
||||
}
|
||||
for (let i = 0; i < tail.length; i++) {
|
||||
if (tail[i].osztaly === Osztaly2E.Tolvaj) {
|
||||
tolvajSzint++
|
||||
tail[i].tolvajExtraKepzettseg = tolvajExtraKepzettsegAtSzint(tolvajSzint, [...prev, current, ...tail.slice(0, i+1)])
|
||||
}
|
||||
}
|
||||
|
||||
const HD = BaseHP(osztaly)
|
||||
if (szint === 1) {
|
||||
@@ -138,6 +174,6 @@ export function ChangeLvl1Osztaly(karakter: Karakter2E, osztaly: Osztaly2E): Kar
|
||||
export function ChangeOsztalyAtSzint(karakter: Karakter2E, osztaly: Osztaly2E, szint: number): Karakter2E {
|
||||
return {
|
||||
...karakter,
|
||||
szintlepesek: setOsztalyForSzintLepes(karakter.szintlepesek, osztaly, szint)
|
||||
szintlepesek: setOsztalyForSzintLepes(karakter, osztaly, szint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,28 @@ describe("Kepzettsegek 2E", () => {
|
||||
const actual = GetAvailableKepzettsegek(karakter)
|
||||
expect(actual).not.toContain(Kepzettsegek.k_meregkeveres)
|
||||
})
|
||||
|
||||
test("at Tolvaj level 9 extra skill can include class-restricted non-Tolvaj skills", () => {
|
||||
const karakter = {
|
||||
...DefaultKarakter,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, tulajdonsagNoveles: Tulajdonsag2E.Ugyesseg },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, tolvajExtraKepzettseg: Kepzettsegek.k_meregkeveres.Id },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
|
||||
]
|
||||
}
|
||||
|
||||
const restricted = GetAvailableKepzettsegek(karakter)
|
||||
expect(restricted).not.toContain(Kepzettsegek.k_csillagjoslas)
|
||||
|
||||
const unrestricted = GetAvailableKepzettsegek(karakter, { ignoreClassRestrictions: true })
|
||||
expect(unrestricted).toContain(Kepzettsegek.k_csillagjoslas)
|
||||
})
|
||||
})
|
||||
test("multiclass characters get access to kepzettsegek from all classes", () => {
|
||||
const karakter = {
|
||||
|
||||
@@ -13,7 +13,7 @@ function availableKezpettsegListFajjal(faj: Faj2E, ...osztalyok: Osztaly2E[]): K
|
||||
}
|
||||
|
||||
export function GetNumberOfKepzettsegek(t_int: number, faj: Faj2E, max: number = 11) {
|
||||
//max = 3 + 1 + 3 + 5 = 12 // 3 base, 1 human, 3 from max ability modifier, 4 from Thief +1 at 9th level
|
||||
//max = 3 + 1 + 3 + 4 + 2 = 13 // 3 base, 1 human, 3 from max ability modifier, 4 from Thief +1 at 5th and +1 at 9th level
|
||||
let numberOfKepzettseg = 3 + Modifier(t_int) + (faj === Faj2E.Ember ? 1 : 0)
|
||||
|
||||
if (numberOfKepzettseg < 1) {
|
||||
@@ -27,9 +27,15 @@ export function GetNumberOfKepzettsegek(t_int: number, faj: Faj2E, max: number =
|
||||
return numberOfKepzettseg;
|
||||
}
|
||||
|
||||
export function GetAvailableKepzettsegek(karakter: Pick<Karakter2E, 'szintlepesek' | 'faj' | 'kepzettsegek' | 'tolvajKepzettsegek'>): Kepzettseg[] {
|
||||
export function GetAvailableKepzettsegek(
|
||||
karakter: Pick<Karakter2E, 'szintlepesek' | 'faj' | 'kepzettsegek' | 'tolvajKepzettsegek'>,
|
||||
options?: { ignoreClassRestrictions?: boolean }
|
||||
): Kepzettseg[] {
|
||||
const osztalyok = new Set(karakter.szintlepesek.map(x => x.osztaly))
|
||||
const availableKepzettsegek = availableKezpettsegListFajjal(karakter.faj, ...osztalyok)
|
||||
const baseKepzettsegList = options?.ignoreClassRestrictions
|
||||
? KepzettsegLista
|
||||
: availableKezpettsegListFajjal(karakter.faj, ...osztalyok)
|
||||
const availableKepzettsegek = baseKepzettsegList
|
||||
.filter(k => !karakter.kepzettsegek.includes(k.Id))
|
||||
|
||||
if (!osztalyok.has(Osztaly2E.Tolvaj)) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {ChangeOsztalyAtSzint, DefaultKarakter, Karakter2E} from "./karakter2E";
|
||||
import {Osztaly2E} from "./osztaly2E";
|
||||
import {LevelUp} from "./szintlepes";
|
||||
|
||||
function applyLevelUp(karakter: Karakter2E): Karakter2E {
|
||||
let next: Karakter2E | undefined;
|
||||
LevelUp(karakter, updated => {
|
||||
next = updated;
|
||||
});
|
||||
expect(next).toBeDefined();
|
||||
return next as Karakter2E;
|
||||
}
|
||||
|
||||
function level1Tolvaj(): Karakter2E {
|
||||
return {
|
||||
...DefaultKarakter,
|
||||
szint: 1,
|
||||
szintlepesek: [{
|
||||
osztaly: Osztaly2E.Tolvaj,
|
||||
HProll: 6,
|
||||
}],
|
||||
tolvajKepzettsegek: [],
|
||||
varazslatok: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Harcos specialization in multiclass level-ups", () => {
|
||||
it("adds a specialization when first Harcos level is gained at character level 2", () => {
|
||||
const afterLevel2 = applyLevelUp(level1Tolvaj());
|
||||
const multiclassed = ChangeOsztalyAtSzint(afterLevel2, Osztaly2E.Harcos, 2);
|
||||
|
||||
const secondLevel = multiclassed.szintlepesek[1];
|
||||
expect(secondLevel?.osztaly).toBe(Osztaly2E.Harcos);
|
||||
expect(secondLevel?.harcosFegyver).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not add a specialization on even Harcos class level", () => {
|
||||
const afterLevel2 = applyLevelUp(level1Tolvaj());
|
||||
const multiclassed = ChangeOsztalyAtSzint(afterLevel2, Osztaly2E.Harcos, 2);
|
||||
const afterLevel3 = applyLevelUp(multiclassed);
|
||||
|
||||
const thirdLevel = afterLevel3.szintlepesek[2];
|
||||
expect(thirdLevel?.osztaly).toBe(Osztaly2E.Harcos);
|
||||
expect(thirdLevel?.harcosFegyver).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds the next specialization on odd Harcos class level after multiclass", () => {
|
||||
const afterLevel2 = applyLevelUp(level1Tolvaj());
|
||||
const multiclassed = ChangeOsztalyAtSzint(afterLevel2, Osztaly2E.Harcos, 2);
|
||||
const afterLevel3 = applyLevelUp(multiclassed);
|
||||
const afterLevel4 = applyLevelUp(afterLevel3);
|
||||
|
||||
const firstHarcosSpec = multiclassed.szintlepesek[1]?.harcosFegyver;
|
||||
const thirdHarcosLevelSpec = afterLevel4.szintlepesek[3]?.harcosFegyver;
|
||||
|
||||
expect(firstHarcosSpec).toBeDefined();
|
||||
expect(thirdHarcosLevelSpec).toBeDefined();
|
||||
expect(thirdHarcosLevelSpec).not.toBe(firstHarcosSpec);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tolvaj extra skill in multiclass level-ups", () => {
|
||||
it("adds extra skill when Tolvaj class reaches level 5 at higher character level", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 5,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
],
|
||||
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const afterLevel6 = applyLevelUp(karakter);
|
||||
const sixthLevel = afterLevel6.szintlepesek[5];
|
||||
|
||||
expect(sixthLevel?.osztaly).toBe(Osztaly2E.Tolvaj);
|
||||
expect(sixthLevel?.tolvajExtraKepzettseg).toBeDefined();
|
||||
});
|
||||
|
||||
it("recomputes missing extra skill when class change makes current level Tolvaj level 5", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 5,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 8 },
|
||||
],
|
||||
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const changed = ChangeOsztalyAtSzint(karakter, Osztaly2E.Tolvaj, 5);
|
||||
const fifthLevel = changed.szintlepesek[4];
|
||||
|
||||
expect(fifthLevel?.osztaly).toBe(Osztaly2E.Tolvaj);
|
||||
expect(fifthLevel?.tolvajExtraKepzettseg).toBeDefined();
|
||||
});
|
||||
|
||||
it("at Tolvaj class level 9 LevelUp can auto-pick a class-restricted non-Tolvaj skill", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 8,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 2, tolvajExtraKepzettseg: "k_meregkeveres" },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
],
|
||||
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const afterLevel9 = applyLevelUp(karakter);
|
||||
expect(afterLevel9.szintlepesek[8]?.tolvajExtraKepzettseg).toBe("k_alkimia");
|
||||
});
|
||||
|
||||
it("at Tolvaj class level 9 class-change recomputation can set non-Tolvaj restricted skill", () => {
|
||||
const karakter: Karakter2E = {
|
||||
...DefaultKarakter,
|
||||
szint: 9,
|
||||
szintlepesek: [
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 2, tolvajExtraKepzettseg: "k_meregkeveres" },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
|
||||
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
|
||||
{ osztaly: Osztaly2E.Harcos, HProll: 8 },
|
||||
],
|
||||
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
|
||||
varazslatok: [],
|
||||
};
|
||||
|
||||
const changed = ChangeOsztalyAtSzint(karakter, Osztaly2E.Tolvaj, 9);
|
||||
expect(changed.szintlepesek[8]?.tolvajExtraKepzettseg).toBe("k_alkimia");
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,9 @@ export function LevelUp(karakter: Karakter2E, changeKarakter: (input: Karakter2E
|
||||
if (autoPickedOsztaly === Osztaly2E.Tolvaj){
|
||||
const tolvajSzint = 1 + karakter.szintlepesek.filter(x => x.osztaly === Osztaly2E.Tolvaj).length
|
||||
if (tolvajSzint === 5 || tolvajSzint === 9) {
|
||||
const availableKepzettsegek = GetAvailableKepzettsegek(karakter)
|
||||
const availableKepzettsegek = GetAvailableKepzettsegek(karakter, {
|
||||
ignoreClassRestrictions: tolvajSzint === 9,
|
||||
})
|
||||
szintlepes.tolvajExtraKepzettseg = availableKepzettsegek[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ function CreateCharacter2E(props: {
|
||||
<h4 className='col align-self-center'>Tanult</h4>
|
||||
</div>
|
||||
<OsztalySelector2E />
|
||||
{karakter.szintlepesek[0].osztaly === Osztaly2E.Harcos && <HarcosFegyverSpecializacio szint={1}/>}
|
||||
{karakter.szintlepesek[0].osztaly === Osztaly2E.Harcos && <HarcosFegyverSpecializacio harcosSzint={1} karakterSzint={1}/>}
|
||||
<KarakterKepzettsegek />
|
||||
<hr/>
|
||||
{/* 1. szinten */}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import * as React from "react";
|
||||
import axios from "axios";
|
||||
import {Container, Nav, Navbar} from "react-bootstrap";
|
||||
import {userSelector} from "./domain-models/userSlice";
|
||||
import {RootState} from "../store";
|
||||
import {useSelector} from "react-redux";
|
||||
|
||||
async function logout() {
|
||||
await axios.post("/Identity/Account/Logout")
|
||||
document.location.reload()
|
||||
}
|
||||
import {unsetUser, userSelector} from "./domain-models/userSlice";
|
||||
import {AppDispatch, RootState} from "../store";
|
||||
import {useDispatch, useSelector} from "react-redux";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {postVoid} from "./api/http";
|
||||
|
||||
function Header() {
|
||||
|
||||
const dispatch = useDispatch.withTypes<AppDispatch>()();
|
||||
const navigate = useNavigate();
|
||||
const fetchedUser = useSelector.withTypes<RootState>()(userSelector);
|
||||
|
||||
const logout = async () => {
|
||||
await postVoid("/Identity/Account/Logout")
|
||||
dispatch(unsetUser())
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
return <header>
|
||||
<Navbar expand="sm" className="navbar-light bg-white border-bottom box-shadow mb-3">
|
||||
<Container className="ps-4 pe-4">
|
||||
<Navbar.Brand href="/">Kemkas</Navbar.Brand>
|
||||
{fetchedUser.email != null && <Nav.Link className="text-dark" href="/karaktereim">Karaktereim</Nav.Link>}
|
||||
<Navbar.Brand as={Link} to="/">Kemkas</Navbar.Brand>
|
||||
{fetchedUser.email != null && <Nav.Link as={Link} className="text-dark" to="/karaktereim">Karaktereim</Nav.Link>}
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav" className="d-sm-inline-flex flex-sm-row-reverse">
|
||||
<Nav>
|
||||
@@ -38,7 +42,7 @@ function Header() {
|
||||
href="/Identity/Account/Manage">Hello {fetchedUser.email}!</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className="nav-link nav text-dark" onClick={logout}>Logout</button>
|
||||
<button type="button" className="nav-link nav text-dark" onClick={logout}>Logout</button>
|
||||
</li>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios, {AxiosRequestConfig} from "axios";
|
||||
|
||||
export const http = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export async function getJson<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await http.get<T>(url, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function getText(url: string, config?: AxiosRequestConfig): Promise<string> {
|
||||
const response = await http.get<string>(url, {
|
||||
...config,
|
||||
responseType: "text",
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function postJson<TRequest, TResponse>(url: string, body: TRequest, config?: AxiosRequestConfig): Promise<TResponse> {
|
||||
const response = await http.post<TResponse>(url, body, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function postVoid<TRequest = undefined>(url: string, body?: TRequest, config?: AxiosRequestConfig): Promise<void> {
|
||||
await http.post(url, body, config)
|
||||
}
|
||||
@@ -1,18 +1,35 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import {createAsyncThunk, createSlice} from '@reduxjs/toolkit'
|
||||
import {getText} from '../api/http'
|
||||
|
||||
type LoadingState = "not-started" | "loading" | "finished"
|
||||
|
||||
export interface UserState {
|
||||
state: LoadingState,
|
||||
email: string | null,
|
||||
}
|
||||
|
||||
export const fetchCurrentUser = createAsyncThunk<string | null, void, { state: { user: UserState } }>(
|
||||
'user/fetchCurrentUser',
|
||||
async () => {
|
||||
try {
|
||||
const userNameResponse = await getText('/api/User/me')
|
||||
return userNameResponse.length > 0 ? userNameResponse : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{
|
||||
condition: (_, {getState}) => getState().user.state === 'not-started',
|
||||
}
|
||||
)
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState: {
|
||||
state: "not-started" as LoadingState,
|
||||
email: null as string | null,
|
||||
},
|
||||
} as UserState,
|
||||
reducers: {
|
||||
load: (state) => {
|
||||
state.state = "loading"
|
||||
return state
|
||||
},
|
||||
setUser: (state, action: {payload: string}) => {
|
||||
state.state = "finished"
|
||||
state.email = action.payload
|
||||
@@ -24,10 +41,24 @@ export const userSlice = createSlice({
|
||||
return state
|
||||
}
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(fetchCurrentUser.pending, (state) => {
|
||||
state.state = 'loading'
|
||||
})
|
||||
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
|
||||
state.state = 'finished'
|
||||
state.email = action.payload
|
||||
})
|
||||
.addCase(fetchCurrentUser.rejected, (state) => {
|
||||
state.state = 'finished'
|
||||
state.email = null
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const { load, setUser, unsetUser } = userSlice.actions
|
||||
export const { setUser, unsetUser } = userSlice.actions
|
||||
|
||||
export const userSelector = userSlice.selectSlice
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import {useLoaderData} from "react-router-dom";
|
||||
import {Link, useLoaderData} from "react-router-dom";
|
||||
import CharacterListItem from "../components/CharacterListItem";
|
||||
import {Faj, FajLabel as FajLabel1E} from "../../first-edition/domain-models/faj";
|
||||
import {Faj2E, FajLabel as FajLabel2E} from "../../second-edition/domain-models/faj2E";
|
||||
@@ -33,7 +33,7 @@ interface CharacterListItemDto2E {
|
||||
edition: "2e"
|
||||
}
|
||||
|
||||
type CharacterListItemDto = CharacterListItemDto1E | CharacterListItemDto2E;
|
||||
export type CharacterListItemDto = CharacterListItemDto1E | CharacterListItemDto2E;
|
||||
|
||||
function mapDtoToViewModel(dto: CharacterListItemDto): CharacterListItemViewModel {
|
||||
if (dto.edition === "1e") {
|
||||
@@ -68,12 +68,12 @@ function CharacterList(props: {}) {
|
||||
<div className="row">
|
||||
<div className="col-6">
|
||||
<div className="d-grid gap-1 m-3 m-lg-5">
|
||||
<a className="btn btn-dark btn-lg" href="/2e/karakter/" type="button">Új 2e karakter létrehozása</a>
|
||||
<Link className="btn btn-dark btn-lg" to="/2e/karakter">Új 2e karakter létrehozása</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<div className="d-grid gap-1 m-3 m-lg-5">
|
||||
<a className="btn btn-dark btn-lg" href="/1e/karakter/" type="button">Új 1e karakter létrehozása</a>
|
||||
<Link className="btn btn-dark btn-lg" to="/1e/karakter">Új 1e karakter létrehozása</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es2020",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
@@ -14,14 +14,16 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"downlevelIteration": true
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
"src",
|
||||
"vite.config.mts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const port = Number(env.PORT || 5173);
|
||||
const httpsEnabled = env.HTTPS === 'true';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port,
|
||||
...(httpsEnabled ? { https: {} } : {}),
|
||||
strictPort: true,
|
||||
allowedHosts: ['localhost', 'host.docker.internal', 'frontend'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest.setup.ts',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
+1264
-8871
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -47,7 +47,7 @@ http {
|
||||
#access_log /var/log/nginx/host.access.log main;
|
||||
|
||||
location / {
|
||||
proxy_pass https://frontend;
|
||||
proxy_pass http://frontend;
|
||||
}
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CERT_DIR="${CERT_DIR:-/certs}"
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
if [ -f "$CERT_DIR/rootCA.pem" ] && [ -f "$CERT_DIR/rootCA.key" ] && [ -f "$CERT_DIR/kemkas.pem" ] && [ -f "$CERT_DIR/kemkas.key" ]; then
|
||||
echo "Using existing TLS material from $CERT_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
apk add --no-cache openssl >/dev/null
|
||||
|
||||
cat > /tmp/kemkas-server.cnf <<'EOF'
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = req_ext
|
||||
|
||||
[dn]
|
||||
CN = localhost
|
||||
|
||||
[req_ext]
|
||||
subjectAltName = @alt_names
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = proxy
|
||||
DNS.3 = host.docker.internal
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
EOF
|
||||
|
||||
cat > /tmp/kemkas-v3.ext <<'EOF'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage=digitalSignature,keyEncipherment
|
||||
extendedKeyUsage=serverAuth
|
||||
subjectAltName=@alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1=localhost
|
||||
DNS.2=proxy
|
||||
DNS.3=host.docker.internal
|
||||
IP.1=127.0.0.1
|
||||
IP.2=::1
|
||||
EOF
|
||||
|
||||
openssl genrsa -out "$CERT_DIR/rootCA.key" 2048
|
||||
openssl req -x509 -new -nodes -key "$CERT_DIR/rootCA.key" -sha256 -days 3650 -subj "/CN=kemkas-local-root" -out "$CERT_DIR/rootCA.pem"
|
||||
|
||||
openssl genrsa -out "$CERT_DIR/kemkas.key" 2048
|
||||
openssl req -new -key "$CERT_DIR/kemkas.key" -out /tmp/kemkas.csr -config /tmp/kemkas-server.cnf
|
||||
openssl x509 -req -in /tmp/kemkas.csr -CA "$CERT_DIR/rootCA.pem" -CAkey "$CERT_DIR/rootCA.key" -CAcreateserial -out "$CERT_DIR/kemkas.pem" -days 825 -sha256 -extfile /tmp/kemkas-v3.ext
|
||||
|
||||
echo "Generated TLS CA and nginx certificate under $CERT_DIR"
|
||||
Reference in New Issue
Block a user