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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
be2a674c7a
commit
2babef9293
@@ -0,0 +1,292 @@
|
||||
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);
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst –
|
||||
* ohne Bilddienst, ohne Netz.
|
||||
*
|
||||
* npm run symbole
|
||||
*
|
||||
* Zwei Fassungen, weil Android sie unterschiedlich behandelt:
|
||||
*
|
||||
* symbol-192.png / symbol-512.png das Zeichen, wie es ist
|
||||
* symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf
|
||||
* Petrol und nach innen gerückt. Android
|
||||
* schneidet das Symbol in eine eigene
|
||||
* Form – ein Kreis, ein Tropfen, was das
|
||||
* Gerät gerade mag. Was zu weit außen
|
||||
* liegt, ist dann weg.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const ZIEL = path.join(process.cwd(), 'public');
|
||||
|
||||
/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */
|
||||
function zeichen(randAnteil: number, eckenRadius: number): string {
|
||||
// Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten.
|
||||
// `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern.
|
||||
const innen = 64 * (1 - 2 * randAnteil);
|
||||
const versatz = 64 * randAnteil;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="${eckenRadius}" fill="#1F4E5F"/>
|
||||
<g transform="translate(${versatz} ${versatz}) scale(${innen / 64})">
|
||||
<rect x="14" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
|
||||
<rect x="27" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
|
||||
<rect x="40" y="16" width="10" height="32" rx="2" fill="#D98A2B"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
type Auftrag = { datei: string; groesse: number; svg: string };
|
||||
|
||||
const auftraege: Auftrag[] = [
|
||||
{ datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) },
|
||||
{ datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) },
|
||||
// Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden.
|
||||
{ datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const auftrag of auftraege) {
|
||||
const seite = await browser.newPage({
|
||||
viewport: { width: auftrag.groesse, height: auftrag.groesse },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await seite.setContent(
|
||||
`<!doctype html><style>html,body{margin:0;padding:0}svg{display:block;width:100vw;height:100vh}</style>${auftrag.svg}`,
|
||||
);
|
||||
|
||||
const datei = path.join(ZIEL, auftrag.datei);
|
||||
await seite.screenshot({ path: datei, omitBackground: false });
|
||||
await seite.close();
|
||||
|
||||
console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('Symbole erzeugt.');
|
||||
}
|
||||
|
||||
main().catch((fehler) => {
|
||||
console.error('Die Symbole konnten nicht erzeugt werden.');
|
||||
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user