Files
2026-10-30 10:11:43 +01:00

139 lines
8.3 KiB
HTML

<!doctype html>
<!--
@file selftest.html
@brief Selbsttest der Simulator-Kernlogik (Bewertung, Auswahl, Import, Validierung) im Browser
@author Björn Runden <br@runden.de>
@owner Björn Runden <br@runden.de>
@assist KI-unterstützt (Claude Opus 5.5), geprüft von Björn Runden <br@runden.de>
@version 0.1.0 (29.10.2026)
@usage im Browser öffnen oder: chromium --headless --dump-dom tests/selftest.html | grep RESULT
-->
<html lang="de">
<head><meta charset="utf-8"><title>Selbsttest LPIC-Simulator</title>
<style>body{background:#282a36;color:#f8f8f2;font:14px monospace;padding:16px}.fail{color:#ff5555}.ok{color:#50fa7b}</style>
</head>
<body>
<pre id="out"></pre>
<script>window.LPICSIM_NO_UI = true;</script>
<script src="../js/bank.js"></script>
<script src="../js/app.js"></script>
<script>
(function () {
'use strict';
const out = document.getElementById('out');
const c = window.LPICSim.core;
let pass = 0, fail = 0;
function t(name, cond) {
const line = document.createElement('div');
line.className = cond ? 'ok' : 'fail';
line.textContent = `${cond ? 'ok ' : 'FAIL'} ${name}`;
out.appendChild(line);
cond ? pass++ : fail++;
}
const single = { id: '101.1-900', objective: '101.1', type: 'single', question: 'q', explanation: 'e',
options: ['a', 'b', 'c', 'd', 'e'].map((id) => ({ id, text: id, correct: id === 'c', explanation: 'x' })) };
const multi = { ...single, id: '101.1-901', type: 'multi',
options: single.options.map((o) => ({ ...o, correct: o.id === 'a' || o.id === 'd' })) };
const fill = { id: '101.1-902', objective: '101.1', type: 'fill', question: 'q', explanation: 'e', answers: ['modprobe', 'modprobe -a'] };
const fillCi = { ...fill, id: '101.1-903', case_sensitive: false };
t('single richtig', c.isCorrect(single, ['c']));
t('single falsch', !c.isCorrect(single, ['a']));
t('single leer falsch', !c.isCorrect(single, []));
t('multi vollständig richtig', c.isCorrect(multi, ['d', 'a']));
t('multi teilweise = falsch', !c.isCorrect(multi, ['a']));
t('multi zu viel = falsch', !c.isCorrect(multi, ['a', 'd', 'e']));
t('fill exakt', c.isCorrect(fill, 'modprobe'));
t('fill Leerraum normalisiert', c.isCorrect(fill, ' modprobe -a '));
t('fill Alternative', c.isCorrect(fill, 'modprobe -a'));
t('fill Groß/klein beachtet', !c.isCorrect(fill, 'Modprobe'));
t('fill case_sensitive=false', c.isCorrect(fillCi, 'MODPROBE'));
t('fill leer falsch', !c.isCorrect(fill, ''));
const objs = new Set(['101.1']);
t('validate ok single', c.validateQuestion(single, objs) === null);
t('validate ok multi', c.validateQuestion(multi, objs) === null);
t('validate ok fill', c.validateQuestion(fill, objs) === null);
t('validate: 4 Optionen abgelehnt', c.validateQuestion({ ...single, options: single.options.slice(0, 4) }, objs) !== null);
t('validate: single mit 2 richtigen abgelehnt', c.validateQuestion({ ...multi, type: 'single' }, objs) !== null);
t('validate: Objective unbekannt', c.validateQuestion({ ...single, objective: '999.9', id: '999.9-001' }, objs) !== null);
t('validate: Option ohne Erläuterung', c.validateQuestion({ ...single,
options: single.options.map((o, i) => (i ? o : { ...o, explanation: '' })) }, objs) !== null);
// ChaCha20: RFC 8439, Abschnitt 2.4.2 (Ergebnis mit Python-cryptography gegengeprüft)
const hex = (h) => new Uint8Array(h.match(/../g).map((b) => parseInt(b, 16)));
const toHex = (u) => Array.from(u, (b) => b.toString(16).padStart(2, '0')).join('');
const rfcKey = new Uint8Array(32).map((_, i) => i);
const rfcNonce = hex('000000000000004a00000000');
const rfcPlain = new TextEncoder().encode("Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.");
const rfcCipher = '6e2e359a2568f98041ba0728dd0d6981e97e7aec1d4360c20a27afccfd9fae0bf91b65c5524733ab8f593dabcd62b3571639d624e65152ab8f530c359f0861d807ca0dbf500d6a6156a38e088a22b65e52bc514d16ccf806818ce91ab77937365af90bbf74a35be6b40b8eedf2785e42874d';
t('ChaCha20 RFC 8439 Testvektor', toHex(c.chacha20(rfcKey, rfcNonce, rfcPlain)) === rfcCipher);
t('ChaCha20 Rückweg', new TextDecoder().decode(c.chacha20(rfcKey, rfcNonce, hex(rfcCipher))) === new TextDecoder().decode(rfcPlain));
// Bank: verschlüsselt ausgeliefert, kein Klartext in bank.js
const raw = window.LPIC_BANK;
t('bank.js enthält nur Chiffrat', !!raw.payload && !JSON.stringify(raw).includes('lsmod') && !JSON.stringify(raw).includes('explanation'));
t('kaputte Bank meldet Fehler', (() => { try { c.decodeBank({ ...raw, rev: '00'.repeat(12) }); return false; } catch (e) { return /nicht lesbar/.test(e.message); } })());
// Bank und Paket-Import
const exams = c.decodeBank(raw).exams;
const ex = exams.find((e) => e.id === 'lpic1-101');
t('Bank geladen', !!ex && ex.bank.length > 0);
const n0 = ex.bank.length;
let r = c.mergePack(exams, { format: 'lpic-sim-questions', exam: 'lpic1-101', questions: [single, { ...single, id: 'kaputt' }] });
t('Paket: 1 neu, 1 abgelehnt', r.added === 1 && r.errors.length === 1 && ex.bank.length === n0 + 1);
r = c.mergePack(exams, { format: 'lpic-sim-questions', exam: 'lpic1-101', questions: [single] });
t('Paket: gleiche id ersetzt', r.replaced === 1 && ex.bank.length === n0 + 1);
r = c.mergePack(exams, { format: 'lpic-sim-questions', exam: 'lpic2-201', questions: [] });
t('Paket: unbekannte Prüfung abgelehnt', r.errors.length === 1);
r = c.mergePack(exams, { format: 'lpic-sim-questions', exam: 'lpic2-201',
definition: { exam: { id: 'lpic2-201', level: 'LPIC-2', title: 'LPIC-2 201', questions: 60, duration_min: 90 },
topics: [{ id: '200', title: 'Kapazität', objectives: [{ id: '200.1', title: 'x', weight: 2 }] }] },
questions: [{ ...single, id: '200.1-001', objective: '200.1' }] });
t('Paket mit definition legt Prüfung an', r.added === 1 && exams.some((e) => e.id === 'lpic2-201'));
// Gewichtete Auswahl
const fake = { questions: 10, duration_min: 90, id: 'x', title: 'x', topics: [{ id: '1', title: 't', objectives: [
{ id: '1.1', weight: 4 }, { id: '1.2', weight: 1 }] }], bank: [] };
for (let i = 0; i < 20; i++) fake.bank.push({ ...single, id: `1.1-${100 + i}`, objective: '1.1' });
for (let i = 0; i < 20; i++) fake.bank.push({ ...single, id: `1.2-${100 + i}`, objective: '1.2' });
const pick = c.pickWeighted(fake, fake.bank, 10);
t('Auswahl: Anzahl 10', pick.length === 10);
t('Auswahl: Gewichtung 8:2', pick.filter((q) => q.objective === '1.1').length === 8);
t('Auswahl: keine Dubletten', new Set(pick.map((q) => q.id)).size === 10);
const few = { ...fake, bank: fake.bank.filter((q) => q.objective === '1.2' || q.id === '1.1-100') };
t('Auswahl: füllt bei Mangel auf', c.pickWeighted(few, few.bank, 10).length === 10);
// Sitzung
const s = c.createSession(ex, { mode: 'exam' });
t('Prüfungssitzung: Zeitlimit 90 min', s.time_limit_s === 5400);
t('Prüfungssitzung: Fragen = min(60, Bank)', s.questions.length === Math.min(60, ex.bank.length));
t('Sitzung gültig', c.validateSession(s) === null);
t('Sitzung ist Kopie', s.questions[0] !== ex.bank.find((q) => q.id === s.questions[0].id));
const sl = c.createSession(ex, { mode: 'learn', topics: ['101'], count: 3, order: 'objective', shuffleOptions: false });
t('Lernsitzung: count und Filter', sl.questions.length === 3 && sl.questions.every((q) => q.objective.startsWith('101.')));
t('Lernsitzung: sortiert, Optionen ungemischt', sl.questions[0].options === undefined || sl.questions[0].options[0].id === 'a');
t('JSON-Rundreise', c.validateSession(JSON.parse(JSON.stringify(s))) === null);
// Auswertung
const q0 = sl.questions[0];
sl.answers[q0.id] = { value: q0.type === 'fill' ? q0.answers[0] : q0.options.filter((o) => o.correct).map((o) => o.id), revealed: true };
let sc = c.score(sl, true);
t('Score Lernmodus zählt nur Aufgelöste', sc.total === 1 && sc.correct === 1 && sc.percent === 100);
sc = c.score(sl, false);
t('Score gesamt', sc.total === 3 && sc.correct === 1 && sc.wrong.length === 2);
t('fmt escaped HTML', c.fmt('<b>x</b>') === '&lt;b&gt;x&lt;/b&gt;');
t('fmt Code und fett', c.fmt('a `ls -l` **b**') === 'a <code>ls -l</code> <strong>b</strong>');
t('fmt kein Markup in Code', c.fmt('`**x**`') === '<code>**x**</code>');
const res = document.createElement('div');
res.id = 'result';
res.textContent = `RESULT pass=${pass} fail=${fail}`;
out.appendChild(res);
})();
</script>
</body>
</html>