Files
lernbar/scripts/pruefungen/baustein-10.mjs
Florian WildeandClaude Opus 5 2babef9293 Baustein 10a: PWA, Offlinebetrieb, Druckansichten
Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
(scripts/symbole.ts, kein Bilddienst), Verknuepfungen zu Lernmodus,
Berichtsheft und Handouts.

Offline gilt eine Regel, und die steht im Dienstarbeiter: Nur das
Lernmaterial liegt im Browserspeicher - Handouts, Merkkarten, Glossar,
die eigene Woche. Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespraeche, Anhaenge und jedes PDF niemals. Das ist keine
Sparsamkeit: Was im Browsercache liegt, ist an der serverseitigen
Rechtepruefung vorbei.

Bewertungen ohne Netz gehen in eine Warteschlange und werden
nachgereicht. Die Schlange haengt an der Benutzerkennung, nicht am
Geraet - auf einem geteilten Werkstattgeraet bekaeme sonst der Zweite
die offenen Bewertungen des Ersten aufs Konto. Der Tag der Bewertung
kommt vom Geraet und wird auf heute gedeckelt (tagDerBewertung, 4
Tests): Eine vorgestellte Uhr darf das Wiederholen nicht wegstellen.

Druck: Wer die dunkle Fassung eingestellt hatte, druckte bisher schwarze
Flaechen. Im Druck wird jetzt die komplette helle Palette gesetzt, dazu
A4-Raender, keine Umbrueche mitten in Karten und Zeilen, aufgeklappte
details. Druckknoepfe bei Handout, Glossar und Auswertung.

Durchstich in scripts/pruefungen/baustein-10.mjs mit echtem Abschalten
des Netzes: 33 Pruefungen, darunter der Nachweis, dass nichts
Bewertungsrelevantes im Speicher landet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:47:06 +02:00

293 lines
11 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 10 – PWA, Offlinebetrieb, Druck.
*
* Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz
* weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen.
* Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen
* Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den
* Browserspeicher.
*
* Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
const browser = await chromium.launch();
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
async function anmelden() {
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', AZUBI.email);
await seite.fill('#passwort', AZUBI.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */
async function dienstarbeiterBereit() {
return seite.evaluate(async () => {
if (!('serviceWorker' in navigator)) return false;
const registrierung = await navigator.serviceWorker.ready;
// `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab.
if (navigator.serviceWorker.controller) return true;
await new Promise((fertig) => {
navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true });
window.setTimeout(fertig, 5000);
});
return Boolean(navigator.serviceWorker.controller || registrierung.active);
});
}
// --- 1. Die Anwendung lässt sich installieren ------------------------------
console.log('\n1. Installierbar');
const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`);
pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`);
const manifestInhalt = await manifest.json().catch(() => null);
pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung');
pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung');
pruefe(
Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'),
'Es enthält ein Symbol für den Beschnitt unter Android',
);
for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) {
const antwort = await kontext.request.get(`${BASIS}${datei}`);
pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`);
}
// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz
// ohne Internet.
await anmelden();
const fremdeQuellen = await seite.evaluate(() =>
[...document.querySelectorAll('script[src], link[href], img[src]')]
.map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '')
.filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)),
);
pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine');
// --- 2. Der Dienstarbeiter übernimmt ---------------------------------------
console.log('\n2. Dienstarbeiter');
pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite');
// Lernmaterial einmal ansehen, damit es im Speicher landet.
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
await seite.goto(`${BASIS}/karten/lernen`);
await seite.waitForSelector('h1, .lernkarte');
// Und etwas, das **nicht** in den Speicher darf.
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('h1');
const imSpeicher = await seite.evaluate(async () => {
const namen = await caches.keys();
const pfade = [];
for (const name of namen) {
const speicher = await caches.open(name);
for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname);
}
return pfade;
});
pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher');
pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher');
// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune.
const verboten = imSpeicher.filter((pfad) =>
/^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad),
);
pruefe(
verboten.length === 0,
'Nichts Bewertungsrelevantes liegt im Browserspeicher',
verboten.join(', ') || 'nichts',
);
pruefe(
!imSpeicher.some((pfad) => pfad.endsWith('/pdf')),
'Kein PDF liegt im Browserspeicher',
);
// --- 3. Ohne Netz --------------------------------------------------------
console.log('\n3. Ohne Netz');
await kontext.setOffline(true);
const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null);
pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz');
pruefe(
(await seite.locator('h1').innerText().catch(() => '')).length > 0,
'Es ist wirklich der Inhalt und keine Fehlerseite',
await seite.locator('h1').innerText().catch(() => '—'),
);
const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null);
pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz');
// Die Netzleiste sagt Bescheid.
await seite.waitForTimeout(500);
const leiste = await seite.locator('.netzleiste').count();
pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint');
// Eine Seite, die nie im Speicher war, landet auf der Offlineseite.
const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null);
const unbekannterText = await seite.locator('body').innerText().catch(() => '');
pruefe(
unbekannterText.includes('Kein Netz'),
'Eine nicht gespeicherte Seite zeigt die Offlineseite',
`HTTP ${unbekannt?.status() ?? '—'}`,
);
// --- 4. Bewertungen gehen nicht verloren -----------------------------------
console.log('\n4. Bewertungen ohne Netz');
await seite.goto(`${BASIS}/karten/lernen`);
const hatKarte = (await seite.locator('.lernkarte').count()) > 0;
if (!hatKarte) {
console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen');
} else {
await seite.click('.lernkarte');
await seite.waitForSelector('.bewerten-knopf');
await seite.click('.bewerten-sitzt');
await seite.waitForTimeout(800);
const schlange = await seite.evaluate(() =>
Object.keys(localStorage)
.filter((s) => s.startsWith('lernbar-warteschlange-'))
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
);
pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`);
pruefe(
schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)),
'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung',
);
const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText();
pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim());
// --- Und wieder im Netz ---
console.log('\n5. Wieder im Netz');
// Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück,
// während der Azubi noch im Lernmodus steht – genau dort muss die
// Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie,
// weil die Schlange dann schon leer ist.
await kontext.setOffline(false);
const bestaetigung = await seite
.locator('.netzleiste')
.filter({ hasText: 'nachgereicht' })
.first()
.innerText({ timeout: 15000 })
.catch(() => '');
pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—');
const nachher = await seite.evaluate(() =>
Object.keys(localStorage)
.filter((s) => s.startsWith('lernbar-warteschlange-'))
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
);
pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`);
// Und die Bewertung ist wirklich angekommen, nicht nur verschwunden.
await seite.goto(`${BASIS}/karten`);
await seite.waitForSelector('h1');
const uebersicht = await seite.locator('body').innerText();
pruefe(
!/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht),
'Die Übersicht kennt den neuen Lernstand',
);
}
await kontext.setOffline(false);
// --- 6. Die Schnittstelle prüft die Rechte ---------------------------------
console.log('\n6. Rechte an der Schnittstelle');
const ohneAnmeldung = await browser.newContext();
const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, {
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
});
pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`);
await ohneAnmeldung.close();
const alsAusbilder = await browser.newContext({ locale: 'de-DE' });
const ausbilderSeite = await alsAusbilder.newPage();
await ausbilderSeite.goto(`${BASIS}/anmelden`);
await ausbilderSeite.fill('#email', 'fw@wildeflorian.de');
await ausbilderSeite.fill('#passwort', 'ausbilder-start');
await ausbilderSeite.click('button[type=submit]');
await ausbilderSeite.waitForURL(`${BASIS}/`);
const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, {
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
});
pruefe(
alsAusbilderAbgewiesen.status() === 403,
'Der Ausbilder darf den Lernstand nicht führen',
`HTTP ${alsAusbilderAbgewiesen.status()}`,
);
await alsAusbilder.close();
// --- 7. Druck --------------------------------------------------------------
console.log('\n7. Druck');
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem
// früher schwarze Flächen aufs Papier gekommen wären.
await seite.evaluate(() => {
document.documentElement.setAttribute('data-thema', 'dunkel');
localStorage.setItem('lernbar-thema', 'dunkel');
});
await seite.emulateMedia({ media: 'print' });
await seite.waitForTimeout(200);
const druckfarben = await seite.evaluate(() => {
const stil = getComputedStyle(document.body);
const wurzel = getComputedStyle(document.documentElement);
return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() };
});
pruefe(
druckfarben.grund === 'rgb(255, 255, 255)',
'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung',
druckfarben.grund,
);
pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text);
pruefe(
druckfarben.flaeche.toLowerCase() === '#f2f2f2',
'Auch die Flächen sind hell',
druckfarben.flaeche,
);
const versteckt = await seite.evaluate(() => {
const kopf = document.querySelector('.kopf');
return kopf ? getComputedStyle(kopf).display : 'keine';
});
pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt);
await seite.emulateMedia({ media: null });
await browser.close();
console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);