chore(version): update version and changelog for release 0.8.125
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s

- Bumped APP_VERSION to 0.8.125 and updated the changelog to reflect recent changes.
- Added new tests for the dashboard API to ensure proper HTTP 200 responses when inner lists are mocked.
- Enhanced the ExerciseListBulkToolbar component with a data-testid for improved testing capabilities.
- Refactored the TrainingPlanningPage by extracting utility functions to trainingPlanningPageHelpers for better code organization.
This commit is contained in:
Lars 2026-05-14 12:48:33 +02:00
parent 1631bd2e02
commit 300d916fad
13 changed files with 635 additions and 110 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from unittest.mock import patch
import pytest import pytest
from fastapi import Query from fastapi import Query
@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from fastapi_param_unwrap import unwrap_query_default from fastapi_param_unwrap import unwrap_query_default
from main import app from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture @pytest.fixture
@ -27,3 +29,41 @@ def test_unwrap_query_default_for_direct_route_calls() -> None:
def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None: def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/dashboard/kpis") r = client.get("/api/dashboard/kpis")
assert r.status_code == 401 assert r.status_code == 401
def _fake_tenant_for_kpis() -> TenantContext:
return TenantContext(
profile_id=42,
global_role="trainer",
effective_club_id=7,
club_ids=frozenset({7}),
memberships=[],
)
@patch("routers.dashboard.list_training_units")
@patch("routers.dashboard.list_exercises_like_get")
def test_dashboard_kpis_200_when_inner_lists_mocked(
mock_list_ex: object,
mock_list_tu: object,
client: TestClient,
) -> None:
mock_list_ex.return_value = []
mock_list_tu.return_value = []
app.dependency_overrides[get_tenant_context] = _fake_tenant_for_kpis
try:
r = client.get("/api/dashboard/kpis")
assert r.status_code == 200, r.text
data = r.json()
assert "year" in data
assert data["draft_count"] == 0
assert data["mine_count"] == 0
assert data["ytd_completed_count"] == 0
th = data["training_home"]
assert th["upcoming"] == []
assert th["planned_with_notes"] == []
assert th["review_pending"] == []
assert mock_list_ex.call_count == 2
assert mock_list_tu.call_count == 3
finally:
app.dependency_overrides.clear()

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.124" APP_VERSION = "0.8.125"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062" DB_SCHEMA_VERSION = "20260514062"
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.125",
"date": "2026-05-13",
"changes": [
"Tests: Playwright 11 (Übungsliste Bulk-Toolbar), 12 (Trainingsplanung); Dashboard-Test 8 prüft HTTP 200 auf /api/dashboard/kpis; pytest test_dashboard_kpis_200_when_inner_lists_mocked.",
"Frontend Phase 3: trainingPlanningPageHelpers.js aus TrainingPlanningPage; ExerciseListBulkToolbar data-testid.",
],
},
{ {
"version": "0.8.124", "version": "0.8.124",
"date": "2026-05-13", "date": "2026-05-13",

View File

@ -9,7 +9,7 @@ export default function ExerciseListBulkToolbar({
if (selectedCount < 1) return null if (selectedCount < 1) return null
return ( return (
<div className="card exercise-bulk-toolbar"> <div className="card exercise-bulk-toolbar" data-testid="exercise-list-bulk-toolbar">
<strong>{selectedCount} ausgewählt</strong> <strong>{selectedCount} ausgewählt</strong>
<button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}> <button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}>
Auswahl aufheben Auswahl aufheben

View File

@ -17,112 +17,22 @@ import {
hydrateExercisePlanningRow, hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections, insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm' } from '../utils/trainingUnitSectionsForm'
import {
trainingVisibilityShortDE,
addDaysIsoDate,
pad2,
toIsoLocal,
mondayIndex,
getCalendarGridRange,
shiftCalendarMonth,
enumerateIsoDays,
WEEKDAYS_DE,
toNumList,
sessionAssignDefaults,
normalizeGroupCoTrainerIds,
filterDirectoryExcludingLead,
} from '../utils/trainingPlanningPageHelpers'
/** Kurz-Anzeige Sichtbarkeit (Trainingsmodule, Übungen) */
function trainingVisibilityShortDE(visibility) {
const v = String(visibility || '').trim().toLowerCase()
if (v === 'official') return 'Öffentliche Bibliothek'
if (v === 'club') return 'Verein'
if (v === 'private') return 'Privat'
return visibility ? String(visibility) : ''
}
function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
return d.toISOString().slice(0, 10)
}
function pad2(n) {
return String(n).padStart(2, '0')
}
function toIsoLocal(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
/** Montag = erster Wochentag (ISO-Woche UI) */
function mondayIndex(d) {
return (d.getDay() + 6) % 7
}
/** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (MoSo) */
function getCalendarGridRange(ym) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0]
const m = parts[1]
if (!y || !m || m < 1 || m > 12) {
const t = new Date()
return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) }
}
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - mondayIndex(first))
const lastMon = mondayIndex(last)
const gridEnd = new Date(last)
gridEnd.setDate(last.getDate() + (6 - lastMon))
return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) }
}
function shiftCalendarMonth(ym, delta) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0] || new Date().getFullYear()
const m = parts[1] || 1
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
}
function enumerateIsoDays(fromIso, toIso) {
const out = []
const cur = new Date(`${fromIso}T12:00:00`)
const end = new Date(`${toIso}T12:00:00`)
while (cur <= end) {
out.push(toIsoLocal(cur))
cur.setDate(cur.getDate() + 1)
}
return out
}
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function toNumList(arr) {
if (!Array.isArray(arr)) return []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
const sessionAssignDefaults = () => ({
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
function normalizeGroupCoTrainerIds(raw) {
if (raw == null) return []
const arr = Array.isArray(raw) ? raw : []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
/** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als CoOption */
function filterDirectoryExcludingLead(directory, excludeLeadPid) {
const ex =
excludeLeadPid != null && excludeLeadPid !== '' && Number.isFinite(Number(excludeLeadPid))
? Number(excludeLeadPid)
: null
if (ex == null) return directory
return directory.filter((m) => Number(m.id) !== ex)
}
function TrainingPlanningPage() { function TrainingPlanningPage() {
const { user } = useAuth() const { user } = useAuth()
const toast = useToast() const toast = useToast()

View File

@ -0,0 +1,106 @@
/** Reine Hilfen für Trainingsplanung (Kalender, Sichtbarkeits-Kurztext, Trainer-Zuordnung). */
export function trainingVisibilityShortDE(visibility) {
const v = String(visibility || '').trim().toLowerCase()
if (v === 'official') return 'Öffentliche Bibliothek'
if (v === 'club') return 'Verein'
if (v === 'private') return 'Privat'
return visibility ? String(visibility) : ''
}
export function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
return d.toISOString().slice(0, 10)
}
export function pad2(n) {
return String(n).padStart(2, '0')
}
export function toIsoLocal(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
/** Montag = erster Wochentag (ISO-Woche UI) */
export function mondayIndex(d) {
return (d.getDay() + 6) % 7
}
/** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (MoSo) */
export function getCalendarGridRange(ym) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0]
const m = parts[1]
if (!y || !m || m < 1 || m > 12) {
const t = new Date()
return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) }
}
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - mondayIndex(first))
const lastMon = mondayIndex(last)
const gridEnd = new Date(last)
gridEnd.setDate(last.getDate() + (6 - lastMon))
return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) }
}
export function shiftCalendarMonth(ym, delta) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0] || new Date().getFullYear()
const m = parts[1] || 1
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
}
export function enumerateIsoDays(fromIso, toIso) {
const out = []
const cur = new Date(`${fromIso}T12:00:00`)
const end = new Date(`${toIso}T12:00:00`)
while (cur <= end) {
out.push(toIsoLocal(cur))
cur.setDate(cur.getDate() + 1)
}
return out
}
export const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
export function toNumList(arr) {
if (!Array.isArray(arr)) return []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
export const sessionAssignDefaults = () => ({
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
export function normalizeGroupCoTrainerIds(raw) {
if (raw == null) return []
const arr = Array.isArray(raw) ? raw : []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
/** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als CoOption */
export function filterDirectoryExcludingLead(directory, excludeLeadPid) {
const ex =
excludeLeadPid != null && excludeLeadPid !== '' && Number.isFinite(Number(excludeLeadPid))
? Number(excludeLeadPid)
: null
if (ex == null) return directory
return directory.filter((m) => Number(m.id) !== ex)
}

View File

@ -1,4 +1,8 @@
{ {
"status": "passed", "status": "failed",
"failedTests": [] "failedTests": [
"d6ae548bbe32e0652471-c2435d34f500841a9fcc",
"d6ae548bbe32e0652471-6495823d1677ce34da5c",
"d6ae548bbe32e0652471-b581d2777c999619d7af"
]
} }

View File

@ -0,0 +1,137 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: dev-smoke-test.spec.js >> 11. Übungsliste: Massenauswahl zeigt Bulk-Toolbar
- Location: tests\dev-smoke-test.spec.js:253:1
# Error details
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
Call log:
- navigating to "http://127.0.0.1:3098/", waiting until "load"
```
# Test source
```ts
1 | const { test, expect } = require('@playwright/test');
2 |
3 | const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
4 | const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
5 |
6 | /** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
7 | async function submitLoginForm(page) {
8 | await page.getByRole('button', { name: 'Anmelden' }).click();
9 | }
10 |
11 | async function login(page) {
> 12 | await page.goto('/');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
13 | await page.waitForLoadState('networkidle');
14 |
15 | // Warte bis Login-Seite geladen ist
16 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
17 |
18 | await page.fill('input[type="email"]', TEST_EMAIL);
19 | await page.fill('input[type="password"]', TEST_PASSWORD);
20 | await submitLoginForm(page);
21 | // Wait until auth is complete: URL leaves /login and Dashboard is rendered
22 | await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
23 | await page.waitForLoadState('networkidle');
24 | }
25 |
26 | test('1. Login funktioniert', async ({ page }) => {
27 | await page.goto('/');
28 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
29 | await page.fill('input[type="email"]', TEST_EMAIL);
30 | await page.fill('input[type="password"]', TEST_PASSWORD);
31 | await submitLoginForm(page);
32 | await page.waitForLoadState('networkidle');
33 |
34 | // Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
35 | const loginButton = page.locator('button:has-text("Login")');
36 | await expect(loginButton).toHaveCount(0, { timeout: 10000 });
37 |
38 | await page.screenshot({ path: 'screenshots/01-nach-login.png' });
39 | console.log('✓ Login erfolgreich');
40 | });
41 |
42 | test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
43 | await login(page);
44 |
45 | // Warte bis Spinner verschwunden
46 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
47 |
48 | // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
49 | const main = page.locator('.app-main');
50 | await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
51 | timeout: 5000,
52 | });
53 | await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
54 |
55 | await page.screenshot({ path: 'screenshots/02-dashboard.png' });
56 | console.log('✓ Dashboard OK');
57 | });
58 |
59 | test('3. Navigation zu Übungen', async ({ page }) => {
60 | await login(page);
61 |
62 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
63 |
64 | // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
65 | await page.setViewportSize({ width: 390, height: 844 });
66 |
67 | // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
68 | const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
69 | await Promise.all([
70 | page.waitForURL(
71 | (u) => {
72 | const path = u.pathname.replace(/\/$/, '') || '/'
73 | return path === '/exercises'
74 | },
75 | { timeout: 15000 },
76 | ),
77 | exercisesLink.click(),
78 | ]);
79 | await page.waitForLoadState('networkidle');
80 |
81 | // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
82 | const main = page.locator('.app-main');
83 | await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
84 | timeout: 10000,
85 | });
86 |
87 | await page.screenshot({ path: 'screenshots/03-uebungen.png' });
88 | console.log('✓ Übungen-Seite erreichbar');
89 | });
90 |
91 | test('4. Navigation zu Vereine', async ({ page }) => {
92 | await login(page);
93 | await page.setViewportSize({ width: 390, height: 844 });
94 |
95 | await page.locator('.bottom-nav a[href="/clubs"]').click();
96 | await page.waitForLoadState('networkidle');
97 |
98 | // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
99 | // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
100 | await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
101 | await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
102 | timeout: 5000,
103 | });
104 |
105 | await page.screenshot({ path: 'screenshots/04-vereine.png' });
106 | console.log('✓ Vereine-Seite erreichbar');
107 | });
108 |
109 | test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
110 | // Desktop-Viewport
111 | await page.setViewportSize({ width: 1280, height: 800 });
112 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,137 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: dev-smoke-test.spec.js >> 12. Trainingsplanung: Seite lädt mit Überschrift
- Location: tests\dev-smoke-test.spec.js:275:1
# Error details
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
Call log:
- navigating to "http://127.0.0.1:3098/", waiting until "load"
```
# Test source
```ts
1 | const { test, expect } = require('@playwright/test');
2 |
3 | const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
4 | const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
5 |
6 | /** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
7 | async function submitLoginForm(page) {
8 | await page.getByRole('button', { name: 'Anmelden' }).click();
9 | }
10 |
11 | async function login(page) {
> 12 | await page.goto('/');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
13 | await page.waitForLoadState('networkidle');
14 |
15 | // Warte bis Login-Seite geladen ist
16 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
17 |
18 | await page.fill('input[type="email"]', TEST_EMAIL);
19 | await page.fill('input[type="password"]', TEST_PASSWORD);
20 | await submitLoginForm(page);
21 | // Wait until auth is complete: URL leaves /login and Dashboard is rendered
22 | await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
23 | await page.waitForLoadState('networkidle');
24 | }
25 |
26 | test('1. Login funktioniert', async ({ page }) => {
27 | await page.goto('/');
28 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
29 | await page.fill('input[type="email"]', TEST_EMAIL);
30 | await page.fill('input[type="password"]', TEST_PASSWORD);
31 | await submitLoginForm(page);
32 | await page.waitForLoadState('networkidle');
33 |
34 | // Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
35 | const loginButton = page.locator('button:has-text("Login")');
36 | await expect(loginButton).toHaveCount(0, { timeout: 10000 });
37 |
38 | await page.screenshot({ path: 'screenshots/01-nach-login.png' });
39 | console.log('✓ Login erfolgreich');
40 | });
41 |
42 | test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
43 | await login(page);
44 |
45 | // Warte bis Spinner verschwunden
46 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
47 |
48 | // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
49 | const main = page.locator('.app-main');
50 | await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
51 | timeout: 5000,
52 | });
53 | await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
54 |
55 | await page.screenshot({ path: 'screenshots/02-dashboard.png' });
56 | console.log('✓ Dashboard OK');
57 | });
58 |
59 | test('3. Navigation zu Übungen', async ({ page }) => {
60 | await login(page);
61 |
62 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
63 |
64 | // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
65 | await page.setViewportSize({ width: 390, height: 844 });
66 |
67 | // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
68 | const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
69 | await Promise.all([
70 | page.waitForURL(
71 | (u) => {
72 | const path = u.pathname.replace(/\/$/, '') || '/'
73 | return path === '/exercises'
74 | },
75 | { timeout: 15000 },
76 | ),
77 | exercisesLink.click(),
78 | ]);
79 | await page.waitForLoadState('networkidle');
80 |
81 | // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
82 | const main = page.locator('.app-main');
83 | await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
84 | timeout: 10000,
85 | });
86 |
87 | await page.screenshot({ path: 'screenshots/03-uebungen.png' });
88 | console.log('✓ Übungen-Seite erreichbar');
89 | });
90 |
91 | test('4. Navigation zu Vereine', async ({ page }) => {
92 | await login(page);
93 | await page.setViewportSize({ width: 390, height: 844 });
94 |
95 | await page.locator('.bottom-nav a[href="/clubs"]').click();
96 | await page.waitForLoadState('networkidle');
97 |
98 | // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
99 | // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
100 | await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
101 | await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
102 | timeout: 5000,
103 | });
104 |
105 | await page.screenshot({ path: 'screenshots/04-vereine.png' });
106 | console.log('✓ Vereine-Seite erreichbar');
107 | });
108 |
109 | test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
110 | // Desktop-Viewport
111 | await page.setViewportSize({ width: 1280, height: 800 });
112 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,137 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: dev-smoke-test.spec.js >> 8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)
- Location: tests\dev-smoke-test.spec.js:165:1
# Error details
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
Call log:
- navigating to "http://127.0.0.1:3098/", waiting until "load"
```
# Test source
```ts
1 | const { test, expect } = require('@playwright/test');
2 |
3 | const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
4 | const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
5 |
6 | /** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
7 | async function submitLoginForm(page) {
8 | await page.getByRole('button', { name: 'Anmelden' }).click();
9 | }
10 |
11 | async function login(page) {
> 12 | await page.goto('/');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
13 | await page.waitForLoadState('networkidle');
14 |
15 | // Warte bis Login-Seite geladen ist
16 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
17 |
18 | await page.fill('input[type="email"]', TEST_EMAIL);
19 | await page.fill('input[type="password"]', TEST_PASSWORD);
20 | await submitLoginForm(page);
21 | // Wait until auth is complete: URL leaves /login and Dashboard is rendered
22 | await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
23 | await page.waitForLoadState('networkidle');
24 | }
25 |
26 | test('1. Login funktioniert', async ({ page }) => {
27 | await page.goto('/');
28 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
29 | await page.fill('input[type="email"]', TEST_EMAIL);
30 | await page.fill('input[type="password"]', TEST_PASSWORD);
31 | await submitLoginForm(page);
32 | await page.waitForLoadState('networkidle');
33 |
34 | // Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
35 | const loginButton = page.locator('button:has-text("Login")');
36 | await expect(loginButton).toHaveCount(0, { timeout: 10000 });
37 |
38 | await page.screenshot({ path: 'screenshots/01-nach-login.png' });
39 | console.log('✓ Login erfolgreich');
40 | });
41 |
42 | test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
43 | await login(page);
44 |
45 | // Warte bis Spinner verschwunden
46 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
47 |
48 | // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
49 | const main = page.locator('.app-main');
50 | await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
51 | timeout: 5000,
52 | });
53 | await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
54 |
55 | await page.screenshot({ path: 'screenshots/02-dashboard.png' });
56 | console.log('✓ Dashboard OK');
57 | });
58 |
59 | test('3. Navigation zu Übungen', async ({ page }) => {
60 | await login(page);
61 |
62 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
63 |
64 | // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
65 | await page.setViewportSize({ width: 390, height: 844 });
66 |
67 | // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
68 | const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
69 | await Promise.all([
70 | page.waitForURL(
71 | (u) => {
72 | const path = u.pathname.replace(/\/$/, '') || '/'
73 | return path === '/exercises'
74 | },
75 | { timeout: 15000 },
76 | ),
77 | exercisesLink.click(),
78 | ]);
79 | await page.waitForLoadState('networkidle');
80 |
81 | // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
82 | const main = page.locator('.app-main');
83 | await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
84 | timeout: 10000,
85 | });
86 |
87 | await page.screenshot({ path: 'screenshots/03-uebungen.png' });
88 | console.log('✓ Übungen-Seite erreichbar');
89 | });
90 |
91 | test('4. Navigation zu Vereine', async ({ page }) => {
92 | await login(page);
93 | await page.setViewportSize({ width: 390, height: 844 });
94 |
95 | await page.locator('.bottom-nav a[href="/clubs"]').click();
96 | await page.waitForLoadState('networkidle');
97 |
98 | // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
99 | // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
100 | await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
101 | await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
102 | timeout: 5000,
103 | });
104 |
105 | await page.screenshot({ path: 'screenshots/04-vereine.png' });
106 | console.log('✓ Vereine-Seite erreichbar');
107 | });
108 |
109 | test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
110 | // Desktop-Viewport
111 | await page.setViewportSize({ width: 1280, height: 800 });
112 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -184,6 +184,17 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
page.on('request', onRequest); page.on('request', onRequest);
const kpisStatuses = [];
const onResponse = (response) => {
try {
const u = response.url();
if (u.includes('/api/dashboard/kpis')) kpisStatuses.push(response.status());
} catch {
/* ignore */
}
};
page.on('response', onResponse);
try { try {
await page.reload({ waitUntil: 'networkidle' }); await page.reload({ waitUntil: 'networkidle' });
@ -199,11 +210,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
expect(profilesMe).toBe(1); expect(profilesMe).toBe(1);
expect(trainingUnits).toBe(0); expect(trainingUnits).toBe(0);
expect(dashboardKpis).toBe(1); expect(dashboardKpis).toBe(1);
expect(kpisStatuses.some((s) => s === 200)).toBe(true);
} finally { } finally {
page.off('request', onRequest); page.off('request', onRequest);
page.off('response', onResponse);
} }
console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis'); console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis (HTTP 200)');
}); });
test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => { test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => {
@ -237,6 +250,39 @@ test('10. Übungsliste: Filter-Dialog öffnet und schließt', async ({ page }) =
console.log('✓ Übungsliste: Filter-Dialog Smoke'); console.log('✓ Übungsliste: Filter-Dialog Smoke');
}); });
test('11. Übungsliste: Massenauswahl zeigt Bulk-Toolbar', async ({ page }, testInfo) => {
await login(page);
await page.goto('/exercises', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
timeout: 15000,
});
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
const grid = main.getByTestId('exercises-list-grid');
const checks = grid.locator('input[type="checkbox"]');
const n = await checks.count();
if (n < 1) {
testInfo.skip(true, 'Keine Übung in der Liste (Bulk-Smoke braucht mind. einen Treffer)');
return;
}
await checks.first().click();
const bulk = main.getByTestId('exercise-list-bulk-toolbar');
await expect(bulk).toBeVisible({ timeout: 5000 });
await expect(bulk.getByRole('button', { name: /Massenänderung/i })).toBeVisible();
console.log('✓ Übungsliste: Bulk-Toolbar nach Auswahl');
});
test('12. Trainingsplanung: Seite lädt mit Überschrift', async ({ page }) => {
await login(page);
await page.goto('/planning', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({
timeout: 20000,
});
console.log('✓ Trainingsplanung: Grundansicht');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await login(page); await login(page);