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
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:
parent
1631bd2e02
commit
300d916fad
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Query
|
||||
|
|
@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
|||
|
||||
from fastapi_param_unwrap import unwrap_query_default
|
||||
from main import app
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
|
||||
@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:
|
||||
r = client.get("/api/dashboard/kpis")
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.124"
|
||||
APP_VERSION = "0.8.125"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514062"
|
||||
|
||||
|
|
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-05-13",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default function ExerciseListBulkToolbar({
|
|||
if (selectedCount < 1) return null
|
||||
|
||||
return (
|
||||
<div className="card exercise-bulk-toolbar">
|
||||
<div className="card exercise-bulk-toolbar" data-testid="exercise-list-bulk-toolbar">
|
||||
<strong>{selectedCount} ausgewählt</strong>
|
||||
<button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}>
|
||||
Auswahl aufheben
|
||||
|
|
|
|||
|
|
@ -17,112 +17,22 @@ import {
|
|||
hydrateExercisePlanningRow,
|
||||
insertTrainingModuleIntoPlanningSections,
|
||||
} 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 (Mo–So) */
|
||||
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 Co‑Option */
|
||||
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() {
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
|
|
|
|||
106
frontend/src/utils/trainingPlanningPageHelpers.js
Normal file
106
frontend/src/utils/trainingPlanningPageHelpers.js
Normal 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 (Mo–So) */
|
||||
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 Co‑Option */
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"d6ae548bbe32e0652471-c2435d34f500841a9fcc",
|
||||
"d6ae548bbe32e0652471-6495823d1677ce34da5c",
|
||||
"d6ae548bbe32e0652471-b581d2777c999619d7af"
|
||||
]
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -184,6 +184,17 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
|
|||
|
||||
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 {
|
||||
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(trainingUnits).toBe(0);
|
||||
expect(dashboardKpis).toBe(1);
|
||||
expect(kpisStatuses.some((s) => s === 200)).toBe(true);
|
||||
} finally {
|
||||
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 }) => {
|
||||
|
|
@ -237,6 +250,39 @@ test('10. Übungsliste: Filter-Dialog öffnet und schließt', async ({ page }) =
|
|||
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 }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user