refactor: optimized python code and offload things to vue code

This commit is contained in:
Matthias Jung
2026-08-04 23:15:17 +02:00
parent e54e711cef
commit e7e852054c
3 changed files with 216 additions and 191 deletions
+110 -28
View File
@@ -7,22 +7,67 @@
var ANSWER_LABELS = ["A", "B", "C", "D"]; var ANSWER_LABELS = ["A", "B", "C", "D"];
var PART_DEFINITIONS = { var PART_DEFINITIONS = {
B: { label: "Betriebliche Kenntnisse", formLabel: "Betriebliche Kenntnisse", minutes: 45 }, B: { label: "Betriebliche Kenntnisse", minutes: 45 },
V: { label: "Kenntnisse von Vorschriften", formLabel: "Kenntnisse von Vorschriften", minutes: 45 }, V: { label: "Kenntnisse von Vorschriften", minutes: 45 },
N: { label: "Technische Kenntnisse Klasse N", formLabel: "Technische Kenntnisse Klasse N", minutes: 45 }, N: { label: "Technische Kenntnisse Klasse N", minutes: 45 },
E: { label: "Technische Kenntnisse Klasse E", formLabel: "Technische Kenntnisse Klasse E", minutes: 45 }, E: { label: "Technische Kenntnisse Klasse E", minutes: 45 },
A: { label: "Technische Kenntnisse Klasse A", formLabel: "Technische Kenntnisse Klasse A", minutes: 60 } A: { label: "Technische Kenntnisse Klasse A", minutes: 60 }
}; };
var EXAM_CHOICES = [ var EXAM_CHOICES = [
{ id: "N", group: "primary", shortLabel: "N", label: "Prüfung Klasse N", parts: ["B", "V", "N"], color: "#47ABE8" }, { id: "N", group: "primary", label: "Klasse N", detail: "B + V + N", parts: ["B", "V", "N"], color: "#47ABE8" },
{ id: "E", group: "primary", shortLabel: "N + E", label: "Prüfung Klasse E", parts: ["B", "V", "N", "E"], color: "#FE756C" }, { id: "E", group: "primary", label: "Klasse E", detail: "B + V + N + E", parts: ["B", "V", "N", "E"], color: "#FE756C" },
{ id: "A", group: "primary", shortLabel: "N + E + A", label: "Prüfung Klasse A", parts: ["B", "V", "N", "E", "A"], color: "#3BB583" }, { id: "A", group: "primary", label: "Klasse A", detail: "B + V + N + E + A", parts: ["B", "V", "N", "E", "A"], color: "#3BB583" },
{ id: "N-E", group: "upgrade", shortLabel: "N → E", label: "Aufstockung von N auf E", parts: ["E"], color: "#FE756C" }, { id: "N-E", group: "upgrade", label: "N → E", detail: "Technik E", parts: ["E"], color: "#FE756C" },
{ id: "E-A", group: "upgrade", shortLabel: "E → A", label: "Aufstockung von E auf A", parts: ["A"], color: "#3BB583" }, { id: "N-A", group: "upgrade", label: "N → A", detail: "Technik E + A", parts: ["E", "A"], color: "#3BB583" },
{ id: "N-A", group: "upgrade", shortLabel: "N → A", label: "Aufstockung von N auf A", parts: ["E", "A"], color: "#3BB583" } { id: "E-A", group: "upgrade", label: "E → A", detail: "Technik A", parts: ["A"], color: "#3BB583" }
]; ];
function normalizeCatalog(rawCatalog, metadata, questionIndex) {
var questions = {};
var partForSection = { 1: "B", 2: "V" };
var partForClass = { "1": "N", "2": "E", "3": "A" };
function pictureId(value) {
return value ? String(value) : "";
}
function walk(section, sectionIndex, category) {
(section.questions || []).forEach(function (question) {
var part = sectionIndex === 0
? partForClass[String(question.class)]
: partForSection[sectionIndex];
if (!part) return;
var questionMetadata = metadata[question.number] || {};
questions[question.number] = {
id: question.number,
part: part,
category: category.slice(),
question: question.question,
layout: questionMetadata.layout || "default",
picture: pictureId(questionMetadata.picture_question),
answers: ["a", "b", "c", "d"].map(function (letter) {
return {
text: question["answer_" + letter] || "",
picture: pictureId(questionMetadata["picture_" + letter])
};
}),
hasSolution: Boolean(questionIndex[question.number] && questionIndex[question.number].has_solution)
};
});
(section.sections || []).forEach(function (child, childIndex) {
walk(child, sectionIndex, category.concat(childIndex));
});
}
(rawCatalog.sections || []).forEach(function (section, sectionIndex) {
walk(section, sectionIndex, []);
});
return questions;
}
function shuffled(values) { function shuffled(values) {
var result = values.slice(); var result = values.slice();
for (var i = result.length - 1; i > 0; i -= 1) { for (var i = result.length - 1; i > 0; i -= 1) {
@@ -120,6 +165,8 @@
var loading = ref(true); var loading = ref(true);
var loadError = ref(""); var loadError = ref("");
var catalog = ref({}); var catalog = ref({});
var altTexts = Vue.reactive({});
var requestedAltTexts = new Set();
var session = ref(null); var session = ref(null);
var view = ref("exam"); var view = ref("exam");
var reviewPartIndex = ref(null); var reviewPartIndex = ref(null);
@@ -199,14 +246,38 @@
return catalog.value[questionState.id]; return catalog.value[questionState.id];
} }
function partSummary(parts) {
return parts.join(" + ");
}
function padNumber(number) { function padNumber(number) {
return String(number).padStart(2, "0"); return String(number).padStart(2, "0");
} }
function pictureAlt(picture, fallback) {
return altTexts[picture] || fallback;
}
function loadAltText(picture) {
if (!picture || requestedAltTexts.has(picture)) return;
requestedAltTexts.add(picture);
fetch("pictures/" + encodeURIComponent(picture) + ".txt")
.then(function (response) {
if (!response.ok) return "";
return response.text();
})
.then(function (description) {
if (description && description.trim()) altTexts[picture] = description.trim();
})
.catch(function () { /* the generic alternative text remains available */ });
}
function loadPartAltTexts(part) {
if (!part) return;
part.questions.forEach(function (questionState) {
var question = questionData(questionState);
if (!question) return;
loadAltText(question.picture);
question.answers.forEach(function (answer) { loadAltText(answer.picture); });
});
}
function createPart(code) { function createPart(code) {
var picked = balancedSample(Object.values(catalog.value).filter(function (question) { var picked = balancedSample(Object.values(catalog.value).filter(function (question) {
return question.part === code; return question.part === code;
@@ -351,8 +422,7 @@
try { window.localStorage.removeItem(STORAGE_KEY); } catch (error) { /* storage can be unavailable */ } try { window.localStorage.removeItem(STORAGE_KEY); } catch (error) { /* storage can be unavailable */ }
} }
function abortExam() { function resetExam() {
if (!window.confirm("Möchtest du diese Simulation abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return;
clearStoredSession(); clearStoredSession();
session.value = null; session.value = null;
view.value = "exam"; view.value = "exam";
@@ -360,12 +430,13 @@
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
function abortExam() {
if (!window.confirm("Möchtest du diese Simulation abbrechen? Der gespeicherte Prüfungsstand wird gelöscht.")) return;
resetExam();
}
function newExam() { function newExam() {
clearStoredSession(); resetExam();
session.value = null;
view.value = "exam";
reviewPartIndex.value = null;
window.scrollTo({ top: 0, behavior: "smooth" });
} }
function scrollToQuestion(index) { function scrollToQuestion(index) {
@@ -445,18 +516,29 @@
} catch (error) { /* simulator remains usable without persistence */ } } catch (error) { /* simulator remains usable without persistence */ }
}, { deep: true }); }, { deep: true });
watch(currentPart, function (part) {
loadPartAltTexts(part);
});
watch(remainingSeconds, function (seconds) { watch(remainingSeconds, function (seconds) {
if (session.value && currentPart.value && currentPart.value.status === "running" && seconds <= 0) submitPart(true); if (session.value && currentPart.value && currentPart.value.status === "running" && seconds <= 0) submitPart(true);
}); });
function loadCatalog() { function loadCatalog() {
fetch("assets/exam-questions.json") Promise.all([
.then(function (response) { fetch("assets/fragenkatalog.json"),
if (!response.ok) throw new Error("Fragenkatalog nicht gefunden (HTTP " + response.status + ")."); fetch("assets/metadata.json"),
return response.json(); fetch("assets/question_index.json")
])
.then(function (responses) {
var names = ["Fragenkatalog", "Metadaten", "Fragenindex"];
responses.forEach(function (response, index) {
if (!response.ok) throw new Error(names[index] + " nicht gefunden (HTTP " + response.status + ").");
});
return Promise.all(responses.map(function (response) { return response.json(); }));
}) })
.then(function (data) { .then(function (data) {
catalog.value = data.questions || {}; catalog.value = normalizeCatalog(data[0], data[1] || {}, data[2] || {});
if (Object.keys(catalog.value).length === 0) throw new Error("Der Fragenkatalog ist leer."); if (Object.keys(catalog.value).length === 0) throw new Error("Der Fragenkatalog ist leer.");
restoreSession(); restoreSession();
loading.value = false; loading.value = false;
@@ -520,8 +602,8 @@
overallIcon: overallIcon, overallIcon: overallIcon,
overallTitle: overallTitle, overallTitle: overallTitle,
overallText: overallText, overallText: overallText,
partSummary: partSummary,
padNumber: padNumber, padNumber: padNumber,
pictureAlt: pictureAlt,
questionData: questionData, questionData: questionData,
startExam: startExam, startExam: startExam,
selectAnswer: selectAnswer, selectAnswer: selectAnswer,
+34 -95
View File
@@ -24,7 +24,11 @@ class Build:
self.env = Environment(loader=FileSystemLoader(self.config.p_templates)) self.env = Environment(loader=FileSystemLoader(self.config.p_templates))
self.env.filters["shuffle_answers"] = self.__filter_shuffle_answers self.env.filters["shuffle_answers"] = self.__filter_shuffle_answers
self.questions = self.__parse_katalog() with self.config.p_data_fragenkatalog.open(encoding="utf-8") as file:
self.fragenkatalog = json.load(file)
with self.config.p_data_metadata.open(encoding="utf-8") as file:
self.question_metadata = json.load(file)
self.questions = self.__parse_katalog(self.fragenkatalog)
self.question_index = {} self.question_index = {}
self.question_token_pattern = re.compile(r"^\s*\[question:([\w\d]+)\]", re.MULTILINE) self.question_token_pattern = re.compile(r"^\s*\[question:([\w\d]+)\]", re.MULTILINE)
@@ -32,22 +36,18 @@ class Build:
self.index_entries = {} self.index_entries = {}
self.index_token_pattern = Index.pattern self.index_token_pattern = Index.pattern
def __parse_katalog(self): @staticmethod
with self.config.p_data_fragenkatalog.open(encoding="utf-8") as file: def __parse_katalog(fragenkatalog):
fragenkatalog = json.load(file)
questions = {} questions = {}
for exampart in fragenkatalog["sections"]: def collect(section):
for chapter in exampart["sections"]: for question in section.get("questions", []):
if "questions" in chapter:
for question in chapter["questions"]:
questions[question["number"]] = question
if "sections" in chapter:
for section in chapter["sections"]:
for question in section["questions"]:
questions[question["number"]] = question questions[question["number"]] = question
for child in section.get("sections", []):
collect(child)
for exam_part in fragenkatalog["sections"]:
collect(exam_part)
return questions return questions
def __build_question(self, number, template_file="html/question.html"): def __build_question(self, number, template_file="html/question.html"):
@@ -55,17 +55,8 @@ class Build:
question_template = self.env.get_template(template_file) question_template = self.env.get_template(template_file)
with (self.config.p_data_metadata).open(encoding="utf-8") as file: question = self.questions.get(number)
metadata_json = json.load(file) metadata = self.question_metadata.get(number)
question = None
metadata = None
if number in self.questions:
question = self.questions[number]
if number in metadata_json:
metadata = metadata_json[number]
if question is None or metadata is None: if question is None or metadata is None:
tqdm.write( tqdm.write(
@@ -148,73 +139,22 @@ class Build:
page_scripts=page_scripts or [], page_scripts=page_scripts or [],
) )
@staticmethod
def __walk_exam_questions(section, category=()):
for question in section.get("questions", []):
yield category, question
for index, child in enumerate(section.get("sections", [])):
yield from Build.__walk_exam_questions(child, (*category, index))
def __build_exam_catalog(self):
"""Create the compact client-side catalog used by the exam simulator."""
with (
self.config.p_data_fragenkatalog.open(encoding="utf-8") as catalog_file,
self.config.p_data_metadata.open(encoding="utf-8") as metadata_file,
):
catalog = json.load(catalog_file)
metadata = json.load(metadata_file)
part_for_section = {1: "B", 2: "V"}
part_for_class = {"1": "N", "2": "E", "3": "A"}
questions = {}
def picture_data(picture_id):
if not picture_id:
return {"picture": "", "pictureAlt": ""}
return {
"picture": picture_id,
"pictureAlt": self.__picture_handler(picture_id) or "Bildbeschreibung noch nicht verfügbar",
}
for section_index, section in enumerate(catalog["sections"]):
for category, question in self.__walk_exam_questions(section):
if section_index == 0:
part = part_for_class.get(str(question.get("class")))
else:
part = part_for_section.get(section_index)
if part is None:
continue
question_metadata = metadata.get(question["number"], {})
question_picture = picture_data(question_metadata.get("picture_question", ""))
answers = []
for answer_letter in "abcd":
answer_picture = picture_data(question_metadata.get(f"picture_{answer_letter}", ""))
answers.append(
{
"text": question.get(f"answer_{answer_letter}", ""),
**answer_picture,
}
)
questions[question["number"]] = {
"id": question["number"],
"part": part,
"category": list(category),
"question": question["question"],
"layout": question_metadata.get("layout", "") or "default",
"answers": answers,
"hasSolution": self.__has_solution(question["number"]),
**question_picture,
}
self.config.p_build_assets.mkdir(parents=True, exist_ok=True)
with (self.config.p_build_assets / "exam-questions.json").open("w", encoding="utf-8") as file:
json.dump({"version": 1, "questions": questions}, file, ensure_ascii=False, separators=(",", ":"))
file.write("\n")
def __build_exam_simulator(self): def __build_exam_simulator(self):
self.__build_exam_catalog() self.config.p_build_assets.mkdir(parents=True, exist_ok=True)
shutil.copyfile(self.config.p_data_fragenkatalog, self.config.p_build_assets / "fragenkatalog.json")
shutil.copyfile(self.config.p_data_metadata, self.config.p_build_assets / "metadata.json")
(self.config.p_build_assets / "exam-questions.json").unlink(missing_ok=True)
picture_fields = ("picture_question", "picture_a", "picture_b", "picture_c", "picture_d")
picture_ids = {
metadata[field]
for metadata in self.question_metadata.values()
for field in picture_fields
if metadata.get(field)
}
for picture_id in picture_ids:
self.__picture_handler(picture_id)
exam_template = self.env.get_template("html/exam-simulator.html") exam_template = self.env.get_template("html/exam-simulator.html")
content = exam_template.render() content = exam_template.render()
page = self.__build_page( page = self.__build_page(
@@ -222,9 +162,6 @@ class Build:
page_stylesheets=["assets/exam-simulator.css"], page_stylesheets=["assets/exam-simulator.css"],
page_scripts=["assets/vue.global.prod.js", "assets/exam-simulator.js"], page_scripts=["assets/vue.global.prod.js", "assets/exam-simulator.js"],
) )
legacy_path = self.config.p_build / "probeprüfung.html"
if legacy_path.exists():
legacy_path.unlink()
with (self.config.p_build / "simulation.html").open("w", encoding="utf-8") as file: with (self.config.p_build / "simulation.html").open("w", encoding="utf-8") as file:
file.write(page) file.write(page)
@@ -232,8 +169,10 @@ class Build:
file = f"{id}.svg" file = f"{id}.svg"
try: try:
shutil.copyfile(self.config.p_data_pictures / file, self.config.p_build_pictures / file) shutil.copyfile(self.config.p_data_pictures / file, self.config.p_build_pictures / file)
if (self.config.p_data_pictures / f"{id}.txt").exists(): description_source = self.config.p_data_pictures / f"{id}.txt"
return (self.config.p_data_pictures / f"{id}.txt").read_text(encoding="utf-8") if description_source.exists():
shutil.copyfile(description_source, self.config.p_build_pictures / description_source.name)
return description_source.read_text(encoding="utf-8")
else: else:
return "Bildbeschreibung noch nicht verfügbar" return "Bildbeschreibung noch nicht verfügbar"
except FileNotFoundError: except FileNotFoundError:
+10 -6
View File
@@ -11,14 +11,15 @@
<section v-else-if="!session" class="mx-auto mb-5 exam-start" aria-labelledby="exam-heading"> <section v-else-if="!session" class="mx-auto mb-5 exam-start" aria-labelledby="exam-heading">
<h1 id="exam-heading">Prüfungssimulation</h1> <h1 id="exam-heading">Prüfungssimulation</h1>
<p class="lead">Wähle die Prüfung, die du unter möglichst realistischen Bedingungen üben möchtest.</p> <p class="lead">Welche Prüfung möchtest du simulieren?</p>
<h2 class="h4 mt-5">Erstprüfung</h2> <h2 class="h4 mt-5">Erstprüfung</h2>
<div class="row row-cols-1 row-cols-md-3 g-2"> <div class="row row-cols-1 row-cols-md-3 g-2">
<div v-for="choice in primaryChoices" :key="choice.id" class="col"> <div v-for="choice in primaryChoices" :key="choice.id" class="col">
<button type="button" class="btn w-100 h-100 py-3 text-white" <button type="button" class="btn w-100 h-100 py-3 text-white"
:style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)"> :style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)">
<span v-text="choice.label + ' (' + partSummary(choice.parts) + ')' "></span> <span class="d-block" v-text="choice.label"></span>
<small class="d-block" v-text="choice.detail"></small>
</button> </button>
</div> </div>
</div> </div>
@@ -28,7 +29,8 @@
<div v-for="choice in upgradeChoices" :key="choice.id" class="col"> <div v-for="choice in upgradeChoices" :key="choice.id" class="col">
<button type="button" class="btn w-100 h-100 py-3 text-white" <button type="button" class="btn w-100 h-100 py-3 text-white"
:style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)"> :style="{ backgroundColor: choice.color, borderColor: choice.color }" @click="startExam(choice)">
<span v-text="choice.label + ' (' + partSummary(choice.parts) + ')' "></span> <span class="d-block" v-text="choice.label"></span>
<small class="d-block" v-text="choice.detail"></small>
</button> </button>
</div> </div>
</div> </div>
@@ -115,7 +117,8 @@
<span v-text="padNumber(questionIndex + 1) + ': '"></span><span v-html="questionData(questionState).question"></span> <span v-text="padNumber(questionIndex + 1) + ': '"></span><span v-html="questionData(questionState).question"></span>
</div> </div>
<div v-if="questionData(questionState).picture" class="question-picture"> <div v-if="questionData(questionState).picture" class="question-picture">
<img :src="'pictures/' + questionData(questionState).picture + '.svg'" :alt="questionData(questionState).pictureAlt"> <img :src="'pictures/' + questionData(questionState).picture + '.svg'"
:alt="pictureAlt(questionData(questionState).picture, 'Abbildung zu Frage ' + questionState.id)">
</div> </div>
<div class="answers"> <div class="answers">
<button v-for="(sourceIndex, answerIndex) in questionState.order" :key="answerIndex" type="button" <button v-for="(sourceIndex, answerIndex) in questionState.order" :key="answerIndex" type="button"
@@ -125,7 +128,8 @@
v-html="questionData(questionState).answers[sourceIndex].text"></span> v-html="questionData(questionState).answers[sourceIndex].text"></span>
<span v-if="questionData(questionState).answers[sourceIndex].picture" class="question-picture"> <span v-if="questionData(questionState).answers[sourceIndex].picture" class="question-picture">
<img :src="'pictures/' + questionData(questionState).answers[sourceIndex].picture + '.svg'" <img :src="'pictures/' + questionData(questionState).answers[sourceIndex].picture + '.svg'"
:alt="questionData(questionState).answers[sourceIndex].pictureAlt"> :alt="pictureAlt(questionData(questionState).answers[sourceIndex].picture,
'Abbildung zu Antwort ' + answerLabels[answerIndex] + ' bei Frage ' + questionState.id)">
</span> </span>
<span v-if="currentPart.status === 'evaluated' && sourceIndex === 0" class="result material-icons">check</span> <span v-if="currentPart.status === 'evaluated' && sourceIndex === 0" class="result material-icons">check</span>
<span v-else-if="currentPart.status === 'evaluated' && questionState.selected === answerIndex" class="result material-icons">clear</span> <span v-else-if="currentPart.status === 'evaluated' && questionState.selected === answerIndex" class="result material-icons">clear</span>
@@ -162,7 +166,7 @@
<div class="border border-dark bg-white p-2"> <div class="border border-dark bg-white p-2">
<h2 class="h5 text-center">Antwortbogen</h2> <h2 class="h5 text-center">Antwortbogen</h2>
<div class="border-top border-bottom border-dark py-1 mb-2"> <div class="border-top border-bottom border-dark py-1 mb-2">
<strong class="d-block" v-text="currentPartDefinition.formLabel"></strong> <strong class="d-block" v-text="currentPartDefinition.label"></strong>
<small><span v-text="currentPartDefinition.minutes"></span> Minuten · 25 Fragen</small> <small><span v-text="currentPartDefinition.minutes"></span> Minuten · 25 Fragen</small>
</div> </div>
<table class="answer-table"> <table class="answer-table">