Files
lernbar/scripts/pruefungen/baustein-9.mjs
Florian WildeandClaude Opus 5 be2a674c7a Baustein 9: Auswertung, Rahmenplanabdeckung, Feedbackgespraech
Die Abdeckung des Ausbildungsrahmenplans ist die Ansicht, die gegenueber
der IHK zaehlt. Sie unterscheidet vier Lagen statt "erledigt/offen":

  vermittelt   eine abgenommene Aufgabe oder ein abgeschlossener
               Checklisten-Durchlauf, oder zwei gelesene Handouts
  angefangen   etwas erledigt, aber noch nicht genug
  geplant      Inhalt vorhanden, nichts davon bearbeitet
  ohne Inhalt  zu dieser Position gibt es im Lehrgang gar nichts

Die vierte Lage ist die wichtigste: Sie ist keine Luecke beim Azubi,
sondern eine im Lehrgang, und wird getrennt ausgewiesen. Die Regel steht
als reine Funktion in src/lib/auswertung/abdeckung.ts, mit 17 Tests.

Das Feedbackgespraech laesst beide Seiten getrennt bewerten. Wo sie um
zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben - in der
Ansicht, in der Uebersicht und im PDF, alle drei ueber dieselbe Funktion
liegtWeitAuseinander(). Nach beidseitiger Bestaetigung ist das Gespraech
unveraenderlich und wird nur noch gelesen.

Durchstich in scripts/pruefungen/baustein-9.mjs: alle fuenf Luecken
erkannt, getrennte Spalten, Sperre serverseitig, PDF nur mit Anmeldung,
ein fremder Azubi bekommt 404 - auch auf das PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:08:49 +02:00

238 lines
9.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chromium } from '@playwright/test';
/**
* Durchstich für Baustein 9.
*
* Geprüft werden die beiden Zusagen, die in diesem Baustein zählen:
*
* 1. Die Rahmenplanabdeckung unterscheidet vier Lagen – und weist Positionen
* ohne Material im Lehrgang getrennt aus (A4, A10, C1, C2, C5).
* 2. Das Feedbackgespräch bewertet beide Seiten getrennt, hebt Abweichungen
* hervor, ist nach beidseitiger Bestätigung unveränderlich und liefert
* ein PDF.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
const browser = await chromium.launch();
const UEBRIGE = [
'sorgfalt', 'dokumentation', 'fachwissen', 'fehlersuche',
'kundenkontakt', 'zuverlaessigkeit', 'lernbereitschaft',
];
/**
* Die Radios selbst sind für das Auge weggeblendet; angeklickt wird die
* Beschriftung – genau wie ein Mensch es täte.
*/
async function waehle(seite, kriterium, wert) {
await seite.click(`label.stufe:has(input[name="stufe-${kriterium}"][value="${wert}"])`);
}
async function neueSitzung(konto) {
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', konto.email);
await seite.fill('#passwort', konto.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
return { kontext, seite };
}
// --- 1. Auswertung ---------------------------------------------------------
console.log('\n1. Auswertung und Rahmenplanabdeckung (Ausbilder)');
const ausbilder = await neueSitzung(AUSBILDER);
await ausbilder.seite.goto(`${BASIS}/auswertung`);
await ausbilder.seite.waitForLoadState('networkidle');
const text = await ausbilder.seite.locator('body').innerText();
pruefe(text.includes('Abdeckung'), 'Die Auswertung zeigt die Rahmenplanabdeckung');
pruefe(/ohne Inhalt/i.test(text), 'Die vierte Lage „ohne Inhalt" kommt vor');
// Die fünf Lücken im Beispielinhalt müssen als eigene Gruppe auftauchen.
const luecken = ['A4', 'A10', 'C1', 'C2', 'C5'];
const gefunden = [];
for (const kuerzel of luecken) {
const treffer = await ausbilder.seite
.locator(`[data-stufe="ohne_inhalt"]:has-text("${kuerzel}")`)
.count()
.catch(() => 0);
if (treffer > 0) gefunden.push(kuerzel);
}
pruefe(
gefunden.length === luecken.length,
'Alle fünf Positionen ohne Material sind als Lücke im Lehrgang gekennzeichnet',
`gefunden: ${gefunden.join(', ') || 'keine'}`,
);
pruefe(/Lernfeld/i.test(text), 'Die Lernfeldabdeckung ist vorhanden');
pruefe(/§|Verordnung|Abschnitt/i.test(text), 'Die Fundstelle in der Verordnung wird genannt');
// --- 2. Feedbackgespräch anlegen ------------------------------------------
console.log('\n2. Feedbackgespräch – Ausbilder legt an und bewertet');
await ausbilder.seite.goto(`${BASIS}/feedback`);
await ausbilder.seite.click('button:has-text("Neues Gespräch anlegen")');
await ausbilder.seite.waitForURL(/\/feedback\/[0-9a-f-]{36}$/);
const gespraechUrl = ausbilder.seite.url();
const gespraechId = gespraechUrl.split('/').pop();
pruefe(Boolean(gespraechId), 'Gespräch angelegt', gespraechId);
// Ausbilder bewertet: Selbstständigkeit 2, alle anderen 4.
await waehle(ausbilder.seite, 'selbststaendigkeit', 2);
for (const k of UEBRIGE) await waehle(ausbilder.seite, k, 4);
await ausbilder.seite.fill('#staerken', 'Arbeitet ruhig und fragt nach, bevor etwas kaputtgeht.');
await ausbilder.seite.fill('#entwicklungsfelder', 'Bei unklarer Lage zu lange allein – früher melden.');
await ausbilder.seite.fill('#ziele', 'Bis Ende November eine Rücksicherung eigenständig durchführen.');
await ausbilder.seite.click('button:has-text("Speichern")');
await ausbilder.seite.waitForSelector('text=Gespeichert', { timeout: 15000 });
pruefe(true, 'Einschätzung des Ausbilders gespeichert');
// --- 3. Azubi sieht das Gespräch und schätzt sich selbst ein ---------------
console.log('\n3. Feedbackgespräch – Azubi schätzt sich selbst ein');
const azubi = await neueSitzung(AZUBI);
await azubi.seite.goto(gespraechUrl);
await azubi.seite.waitForLoadState('networkidle');
const azubiText = await azubi.seite.locator('body').innerText();
pruefe(azubiText.includes('Feedbackgespräch'), 'Der Azubi sieht sein Gespräch');
pruefe(
azubiText.includes('Arbeitet ruhig und fragt nach'),
'Der Azubi liest die Freitexte des Ausbilders',
);
pruefe(
(await azubi.seite.locator('#staerken').count()) === 0,
'Der Azubi kann die Freitexte nicht bearbeiten',
);
// Azubi schätzt sich bei Selbstständigkeit deutlich höher ein: 5 gegen 2.
await waehle(azubi.seite, 'selbststaendigkeit', 5);
for (const k of UEBRIGE) await waehle(azubi.seite, k, 4);
await azubi.seite.click('button:has-text("Speichern")');
await azubi.seite.waitForSelector('text=Selbsteinschätzung ist gespeichert', { timeout: 15000 });
await azubi.seite.reload();
const nachSpeichern = await azubi.seite.locator('body').innerText();
pruefe(
/Unterschiedliche Sicht/i.test(nachSpeichern),
'Die Abweichung von drei Stufen wird hervorgehoben',
);
// Gegenprobe: Die Spalte des Ausbilders blieb unangetastet.
await ausbilder.seite.reload();
pruefe(
await ausbilder.seite.isChecked('input[name="stufe-selbststaendigkeit"][value="2"]'),
'Der Azubi hat die Einschätzung des Ausbilders nicht überschrieben',
);
// --- 4. Beidseitige Bestätigung sperrt -------------------------------------
console.log('\n4. Bestätigung durch beide Seiten');
await azubi.seite.click('button:has-text("Gespräch bestätigen")');
await azubi.seite.waitForSelector('text=Bestätigt', { timeout: 15000 });
pruefe(true, 'Azubi hat bestätigt');
// Erst ohne, dann mit Neuladen – so ist ein Rennen von einem echten Fehler
// zu unterscheiden.
const sofort = await azubi.seite
.locator('button:has-text("Bestätigung zurücknehmen")')
.waitFor({ timeout: 5000 })
.then(() => true, () => false);
await azubi.seite.reload();
const nachLaden = (await azubi.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) > 0;
pruefe(
nachLaden,
'Solange nur eine Seite bestätigt hat, lässt sich das zurücknehmen',
sofort ? 'sofort sichtbar' : 'erst nach dem Neuladen sichtbar',
);
await ausbilder.seite.reload();
await ausbilder.seite.click('button:has-text("Als Ausbilder bestätigen")');
await ausbilder.seite.waitForSelector('text=unveränderlich', { timeout: 15000 });
await ausbilder.seite.reload();
const gesperrt = await ausbilder.seite.locator('body').innerText();
pruefe(/abgeschlossen und unveränderlich/.test(gesperrt), 'Das Gespräch ist jetzt abgeschlossen');
pruefe(
(await ausbilder.seite.locator('button:has-text("Speichern")').count()) === 0,
'Es gibt keinen Speichern-Knopf mehr',
);
pruefe(
(await ausbilder.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) === 0,
'Die Bestätigung lässt sich nicht mehr zurücknehmen',
);
// Serverseitige Gegenprobe: Schreiben trotz Sperre muss abgewiesen werden.
await azubi.seite.reload();
pruefe(
await azubi.seite.isDisabled('input[name="stufe-selbststaendigkeit"][value="5"]'),
'Auch beim Azubi sind die Felder gesperrt',
);
// --- 5. PDF ----------------------------------------------------------------
console.log('\n5. PDF für die Akte');
const pdfAntwort = await ausbilder.kontext.request.get(`${gespraechUrl}/pdf`);
const pdfKoerper = await pdfAntwort.body();
pruefe(pdfAntwort.status() === 200, 'PDF wird ausgeliefert', `HTTP ${pdfAntwort.status()}`);
pruefe(pdfKoerper.subarray(0, 5).toString() === '%PDF-', 'Die Antwort ist wirklich ein PDF');
pruefe(pdfKoerper.byteLength > 3000, 'Das PDF hat Inhalt', `${pdfKoerper.byteLength} Bytes`);
// Und ohne Anmeldung? Ein PDF aus der Ausbildungsakte darf nicht herausfallen.
const fremd = await browser.newContext();
const ohne = await fremd.request.get(`${gespraechUrl}/pdf`, { maxRedirects: 0 });
pruefe(
ohne.status() >= 300 && ohne.status() < 400,
'Ohne Anmeldung kommt kein PDF heraus',
`HTTP ${ohne.status()} → ${ohne.headers()['location'] ?? '—'}`,
);
await fremd.close();
// --- 6. Fremdzugriff -------------------------------------------------------
console.log('\n6. Rechte');
const fremderAzubi = await browser.newContext({ locale: 'de-DE' });
const fremdSeite = await fremderAzubi.newPage();
await fremdSeite.goto(`${BASIS}/anmelden`);
await fremdSeite.fill('#email', 'lea.hoffmann@betrieb.de');
await fremdSeite.fill('#passwort', 'azubi-start-2026');
await fremdSeite.click('button[type=submit]');
const angemeldet = await fremdSeite.waitForURL(`${BASIS}/`, { timeout: 8000 }).then(
() => true,
() => false,
);
if (angemeldet) {
const antwort = await fremdSeite.goto(gespraechUrl);
pruefe(antwort?.status() === 404, 'Ein anderer Azubi bekommt 404', `HTTP ${antwort?.status()}`);
const fremdPdf = await fremderAzubi.request.get(`${gespraechUrl}/pdf`);
pruefe(fremdPdf.status() === 404, 'Auch das PDF bleibt verschlossen', `HTTP ${fremdPdf.status()}`);
} else {
console.log(' – zweites Azubikonto nicht vorhanden, Fremdzugriff übersprungen');
}
await fremderAzubi.close();
await browser.close();
console.log(`\n${fehler === 0 ? '✓ Baustein 9 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);