From 8a533dbf1396e6e8e056810043a016704d39a01f Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Fri, 12 Jun 2026 18:44:26 +0200
Subject: [PATCH 1/5] feat: add exam report dialog and session structure
---
src/gui/examreportdialog.cpp | 179 +++++++++++++++++++++++++++++++++++
src/gui/examreportdialog.h | 64 +++++++++++++
src/gui/examsession.h | 98 +++++++++++++++++++
3 files changed, 341 insertions(+)
create mode 100644 src/gui/examreportdialog.cpp
create mode 100644 src/gui/examreportdialog.h
create mode 100644 src/gui/examsession.h
diff --git a/src/gui/examreportdialog.cpp b/src/gui/examreportdialog.cpp
new file mode 100644
index 0000000..dfc3f4c
--- /dev/null
+++ b/src/gui/examreportdialog.cpp
@@ -0,0 +1,179 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "examreportdialog.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+constexpr auto kPassColor = "#11B3BC";
+constexpr auto kFailColor = "#E0635F";
+
+QString escaped(QString text) {
+ return text.toHtmlEscaped().replace(QStringLiteral("\n"),
+ QStringLiteral("
"));
+}
+} // namespace
+
+ExamReportDialog::ExamReportDialog(const ExamSession& session,
+ const QString& examTitle, QWidget* parent)
+ : QDialog(parent) {
+ setObjectName("examReportDialog");
+ setProperty("examReport", true);
+ setWindowTitle(tr("Informe del examen"));
+ setModal(true);
+ resize(720, 600);
+ setMinimumSize(620, 480);
+
+ const double grade = session.grade();
+ const QString gradeText = QLocale().toString(grade, 'f', 1);
+ const bool passed = grade >= 5.0;
+ const int percent =
+ session.total() == 0
+ ? 0
+ : static_cast(100.0 * session.right() / session.total());
+
+ auto* rootLayout = new QVBoxLayout(this);
+ rootLayout->setContentsMargins(28, 24, 28, 24);
+ rootLayout->setSpacing(12);
+
+ auto* eyebrow = new QLabel(tr("MODO EXAMEN"), this);
+ eyebrow->setObjectName("examReportEyebrow");
+ rootLayout->addWidget(eyebrow);
+
+ auto* title = new QLabel(examTitle, this);
+ title->setObjectName("examReportTitle");
+ rootLayout->addWidget(title);
+
+ auto* headerLayout = new QHBoxLayout();
+ headerLayout->setSpacing(16);
+
+ auto* gradeLabel = new QLabel(gradeText, this);
+ gradeLabel->setObjectName("examReportGrade");
+ gradeLabel->setProperty("passed", passed);
+ headerLayout->addWidget(gradeLabel, 0, Qt::AlignTop);
+
+ auto* statsColumn = new QVBoxLayout();
+ statsColumn->setSpacing(2);
+ auto* gradeCaption = new QLabel(tr("sobre 10"), this);
+ gradeCaption->setObjectName("examReportGradeCaption");
+ statsColumn->addWidget(gradeCaption);
+ auto* statsLabel = new QLabel(tr("%1 de %2 respuestas correctas (%3%)")
+ .arg(session.right())
+ .arg(session.total())
+ .arg(percent),
+ this);
+ statsLabel->setObjectName("examReportStats");
+ statsColumn->addWidget(statsLabel);
+ auto* verdictLabel =
+ new QLabel(passed ? tr("Aprobado") : tr("Suspenso"), this);
+ verdictLabel->setObjectName("examReportVerdict");
+ verdictLabel->setProperty("passed", passed);
+ statsColumn->addWidget(verdictLabel);
+ statsColumn->addStretch(1);
+ headerLayout->addLayout(statsColumn, 1);
+
+ rootLayout->addLayout(headerLayout);
+
+ auto* reviewCaption = new QLabel(tr("REVISIÓN"), this);
+ reviewCaption->setObjectName("examReportReviewCaption");
+ rootLayout->addWidget(reviewCaption);
+
+ auto* review = new QTextBrowser(this);
+ review->setObjectName("examReportReview");
+ review->setOpenExternalLinks(false);
+ const QString reviewHtml = buildReviewHtml(session);
+ review->setHtml(reviewHtml);
+ rootLayout->addWidget(review, 1);
+
+ reportHtml_ = QStringLiteral("%1
"
+ "%2 %3 — %4
%5")
+ .arg(escaped(examTitle), tr("Calificación:"),
+ tr("%1 / 10").arg(gradeText),
+ tr("%1 de %2 respuestas correctas (%3%)")
+ .arg(session.right())
+ .arg(session.total())
+ .arg(percent),
+ reviewHtml);
+
+ auto* footerLayout = new QHBoxLayout();
+ footerLayout->setSpacing(12);
+ footerLayout->addStretch(1);
+
+ auto* exportButton = new QPushButton(tr("Exportar PDF"), this);
+ exportButton->setObjectName("examReportExportButton");
+ exportButton->setProperty("role", "primary");
+ exportButton->setAutoDefault(false);
+ exportButton->setCursor(Qt::PointingHandCursor);
+ footerLayout->addWidget(exportButton);
+
+ auto* closeButton = new QPushButton(tr("Cerrar"), this);
+ closeButton->setObjectName("examReportCloseButton");
+ closeButton->setAutoDefault(false);
+ closeButton->setCursor(Qt::PointingHandCursor);
+ footerLayout->addWidget(closeButton);
+
+ rootLayout->addLayout(footerLayout);
+
+ connect(exportButton, &QPushButton::clicked, this,
+ &ExamReportDialog::exportRequested);
+ connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
+}
+
+QString ExamReportDialog::buildReviewHtml(const ExamSession& session) const {
+ QString html = QStringLiteral("");
+ html +=
+ QStringLiteral(""
+ "| %1 | "
+ "%2 | "
+ "%3 | "
+ " | "
+ "
")
+ .arg(tr("Pregunta"), tr("Tu respuesta"), tr("Respuesta correcta"));
+
+ int index = 0;
+ for (const ExamRecord& record : session.records()) {
+ const QString rowColor = (index % 2 == 0) ? QStringLiteral("#212526")
+ : QStringLiteral("#1B1F20");
+ const QString mark =
+ record.correct ? QStringLiteral("✔")
+ .arg(kPassColor)
+ : QStringLiteral("✘")
+ .arg(kFailColor);
+ html += QStringLiteral(""
+ "| %2 | "
+ "%3 | "
+ "%4 | "
+ "%5 | "
+ "
")
+ .arg(rowColor, escaped(record.question),
+ record.userAnswer.trimmed().isEmpty()
+ ? tr("(vacía)")
+ : escaped(record.userAnswer),
+ escaped(record.correctAnswer), mark);
+ ++index;
+ }
+ html += QStringLiteral("
");
+ return html;
+}
diff --git a/src/gui/examreportdialog.h b/src/gui/examreportdialog.h
new file mode 100644
index 0000000..f142250
--- /dev/null
+++ b/src/gui/examreportdialog.h
@@ -0,0 +1,64 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef EXAMREPORTDIALOG_H
+#define EXAMREPORTDIALOG_H
+
+#include "examsession.h"
+#include
+
+/**
+ * @class ExamReportDialog
+ * @brief End-of-exam report: grade, stats and a question-by-question review.
+ *
+ * Shown when an exam-mode exercise finishes. The headline is the 0-10
+ * grade; below it a review lists every graded item with the student's
+ * answer and the expected one, like a corrected exam sheet. The full report
+ * can be exported to PDF through the owning tutor window.
+ */
+class ExamReportDialog : public QDialog {
+ Q_OBJECT
+ public:
+ /**
+ * @brief Builds the report for a finished exam session.
+ *
+ * @param session Graded exam data.
+ * @param examTitle Exercise name shown in the header (e.g. "Examen
+ * LL(1)").
+ * @param parent Parent widget.
+ */
+ ExamReportDialog(const ExamSession& session, const QString& examTitle,
+ QWidget* parent = nullptr);
+
+ /**
+ * @brief Full report as a printable HTML document (used for the PDF
+ * export).
+ */
+ QString reportHtml() const { return reportHtml_; }
+
+ signals:
+ /// @brief Emitted when the user asks to export the report to PDF.
+ void exportRequested();
+
+ private:
+ QString buildReviewHtml(const ExamSession& session) const;
+
+ QString reportHtml_;
+};
+
+#endif // EXAMREPORTDIALOG_H
diff --git a/src/gui/examsession.h b/src/gui/examsession.h
new file mode 100644
index 0000000..c719b8b
--- /dev/null
+++ b/src/gui/examsession.h
@@ -0,0 +1,98 @@
+/*
+ * SyntaxTutor - Interactive Tutorial About Syntax Analyzers
+ * Copyright (C) 2025 Jose R. (jose-rzm)
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef EXAMSESSION_H
+#define EXAMSESSION_H
+
+#include
+#include
+
+/**
+ * @struct ExamRecord
+ * @brief One graded item of an exam: a question (or table cell), the user's
+ * answer, the expected answer, and whether it was correct.
+ */
+struct ExamRecord {
+ /// @brief Question text as it was shown to the student.
+ QString question;
+ /// @brief Answer the student gave.
+ QString userAnswer;
+ /// @brief Expected answer.
+ QString correctAnswer;
+ /// @brief Whether the student's answer was correct.
+ bool correct;
+};
+
+/**
+ * @class ExamSession
+ * @brief Accumulates graded answers during an exam-mode exercise.
+ *
+ * In exam mode the tutors give no feedback: every answer is recorded here
+ * with its real correctness while the exercise advances along the correct
+ * path. At the end the session produces the stats and the 0-10 grade shown
+ * in the exam report.
+ */
+class ExamSession {
+ public:
+ /**
+ * @brief Records one graded item.
+ *
+ * @param question Question text shown to the student.
+ * @param userAnswer Answer the student gave.
+ * @param correctAnswer Expected answer.
+ * @param correct Whether the answer was correct.
+ */
+ void record(const QString& question, const QString& userAnswer,
+ const QString& correctAnswer, bool correct) {
+ records_.push_back({question, userAnswer, correctAnswer, correct});
+ }
+
+ /// @brief Total number of graded items.
+ int total() const { return static_cast(records_.size()); }
+
+ /// @brief Number of correct items.
+ int right() const {
+ int count = 0;
+ for (const ExamRecord& record : records_) {
+ if (record.correct) {
+ ++count;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * @brief Grade on the Spanish 0-10 scale.
+ *
+ * An empty exam grades 0.
+ */
+ double grade() const {
+ if (records_.isEmpty()) {
+ return 0.0;
+ }
+ return 10.0 * right() / total();
+ }
+
+ /// @brief All graded items in exam order.
+ const QVector& records() const { return records_; }
+
+ private:
+ QVector records_;
+};
+
+#endif // EXAMSESSION_H
From f10a4a28193323e0d2eb96c4321748bde02b277d Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Fri, 12 Jun 2026 18:44:39 +0200
Subject: [PATCH 2/5] chore: update pro file
---
SyntaxTutor.pro | 3 +++
1 file changed, 3 insertions(+)
diff --git a/SyntaxTutor.pro b/SyntaxTutor.pro
index 5773675..4e25def 100644
--- a/SyntaxTutor.pro
+++ b/SyntaxTutor.pro
@@ -33,6 +33,7 @@ SOURCES += \
src/backend/symbol_table.cpp \
src/widgets/customtextedit.cpp \
src/widgets/grammarview.cpp \
+ src/gui/examreportdialog.cpp \
src/gui/grammareditordialog.cpp \
src/gui/lltabledialog.cpp \
src/gui/lltutorwindow.cpp \
@@ -55,6 +56,8 @@ HEADERS += \
src/backend/symbol_table.hpp \
src/widgets/customtextedit.h \
src/widgets/grammarview.h \
+ src/gui/examreportdialog.h \
+ src/gui/examsession.h \
src/gui/grammareditordialog.h \
src/gui/lltabledialog.h \
src/gui/lltutorwindow.h \
From 80b25b16ad75339e05836339526b33b8459f23c2 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Fri, 12 Jun 2026 18:45:01 +0200
Subject: [PATCH 3/5] feat: develop exam mode
---
src/gui/lltutorwindow.cpp | 208 +++++++++++++++++++++++++---
src/gui/lltutorwindow.h | 25 +++-
src/gui/mainwindow.cpp | 22 +--
src/gui/mainwindow.h | 6 +-
src/gui/mainwindow.ui | 13 ++
src/gui/slrtabledialog.cpp | 4 +
src/gui/slrtabledialog.h | 9 ++
src/gui/slrtutorwindow.cpp | 274 ++++++++++++++++++++++++++++++++++---
src/gui/slrtutorwindow.h | 23 +++-
9 files changed, 526 insertions(+), 58 deletions(-)
diff --git a/src/gui/lltutorwindow.cpp b/src/gui/lltutorwindow.cpp
index 498355c..36284b5 100644
--- a/src/gui/lltutorwindow.cpp
+++ b/src/gui/lltutorwindow.cpp
@@ -17,6 +17,7 @@
*/
#include "lltutorwindow.h"
+#include "examreportdialog.h"
#include "grammarview.h"
#include "tutorialmanager.h"
#include "ui_lltutorwindow.h"
@@ -104,9 +105,9 @@ ParsedSymbols ParseSymbolList(const QString& input) {
} // namespace
LLTutorWindow::LLTutorWindow(const Grammar& grammar, TutorialManager* tm,
- QWidget* parent)
+ QWidget* parent, bool examMode)
: QWidget(parent), ui(new Ui::LLTutorWindow), grammar(grammar),
- ll1(this->grammar), tm(tm) {
+ ll1(this->grammar), examMode(examMode), tm(tm) {
// ====== Parser & Grammar Setup ===========================
ll1.CreateLL1Table();
#ifdef QT_DEBUG
@@ -155,12 +156,20 @@ LLTutorWindow::LLTutorWindow(const Grammar& grammar, TutorialManager* tm,
ui->cntRight->setText(QString::number(cntRightAnswers));
ui->cntWrong->setText(QString::number(cntWrongAnswers));
+ // In exam mode the live counters would leak feedback; hide them.
+ if (examMode) {
+ ui->tick->hide();
+ ui->cross->hide();
+ ui->cntRight->hide();
+ ui->cntWrong->hide();
+ }
+
updateProgressPanel();
addGrammarMessage();
currentState = State::A;
updatePlaceholder();
- addMessage(generateQuestion(), false);
+ postQuestion();
ui->userResponse->clear();
@@ -707,6 +716,20 @@ void LLTutorWindow::handleTableSubmission(const QVector>& raw,
lastWrongCells.clear();
bool ok = verifyResponseForC();
+ if (examMode) {
+ // Single submission: grade every cell, no highlights or retries.
+ scoreExamTable();
+ if (ok) {
+ ++cntRightAnswers;
+ } else {
+ ++cntWrongAnswers;
+ }
+ currentDlg->accept();
+ on_confirmButton_clicked();
+ currentDlg = nullptr;
+ return;
+ }
+
if (ok) {
currentDlg->accept();
on_confirmButton_clicked();
@@ -738,6 +761,111 @@ void LLTutorWindow::handleTableSubmission(const QVector>& raw,
}
}
+QString LLTutorWindow::examSolutionText() {
+ auto joinSorted = [](const QSet& set) {
+ QStringList values = set.values();
+ std::sort(values.begin(), values.end());
+ return values.join(", ");
+ };
+
+ switch (currentState) {
+ case State::A:
+ case State::A_prime:
+ return solutionForA().join(',');
+ case State::A1:
+ return solutionForA1();
+ case State::A2:
+ return solutionForA2();
+ case State::B:
+ case State::B_prime:
+ return joinSorted(solutionForB());
+ case State::B1:
+ return joinSorted(solutionForB1());
+ case State::B2:
+ return joinSorted(solutionForB2());
+ default:
+ return {};
+ }
+}
+
+void LLTutorWindow::scoreExamTable() {
+ const QString emptyCell = tr("(vacía)");
+
+ for (const auto& [nonTerminal, columns] : ll1.ll1_t_) {
+ const QString nt = QString::fromStdString(nonTerminal);
+
+ for (const auto& [terminal, productions] : columns) {
+ const QString t = QString::fromStdString(terminal);
+ const auto& expected = productions[0];
+
+ const QStringList entry = lltable.value(nt).value(t);
+ if (expected.empty()) {
+ continue;
+ }
+
+ const QString expectedText =
+ QStringList::fromVector(stdVectorToQVector(expected))
+ .join(' ');
+ const QString userText =
+ entry.isEmpty() ? emptyCell : entry.join(' ');
+ examSession.record(tr("Tabla LL(1): celda (%1, %2)").arg(nt, t),
+ userText, expectedText,
+ expected == qvectorToStdVector(entry));
+ }
+ }
+
+ // Cells the user filled although they must stay empty.
+ for (auto itNT = lltable.cbegin(); itNT != lltable.cend(); ++itNT) {
+ const QString nt = itNT.key();
+ auto itSysNT = ll1.ll1_t_.find(nt.toStdString());
+
+ for (auto itT = itNT->cbegin(); itT != itNT->cend(); ++itT) {
+ const QString t = itT.key();
+
+ bool expectedEmpty = true;
+ if (itSysNT != ll1.ll1_t_.end()) {
+ auto itSysT = itSysNT->second.find(t.toStdString());
+ if (itSysT != itSysNT->second.end() &&
+ !itSysT->second[0].empty()) {
+ expectedEmpty = false;
+ }
+ }
+ if (expectedEmpty && !itT->isEmpty()) {
+ examSession.record(tr("Tabla LL(1): celda (%1, %2)").arg(nt, t),
+ itT->join(' '), emptyCell, false);
+ }
+ }
+ }
+}
+
+void LLTutorWindow::showExamReport() {
+ auto* report = new ExamReportDialog(examSession, tr("Examen LL(1)"), this);
+ report->setAttribute(Qt::WA_DeleteOnClose);
+ report->setWindowModality(Qt::WindowModal);
+ connect(report, &ExamReportDialog::exportRequested, this,
+ [this, report]() {
+ const QString filePath = promptExportFilePath();
+ if (!filePath.isEmpty()) {
+ exportExamReportToPdf(filePath, report->reportHtml());
+ }
+ });
+ report->show();
+}
+
+void LLTutorWindow::exportExamReportToPdf(const QString& filePath,
+ const QString& html) const {
+ QTextDocument doc;
+ doc.setHtml(html);
+
+ QPrinter printer(QPrinter::HighResolution);
+ printer.setOutputFormat(QPrinter::PdfFormat);
+ printer.setOutputFileName(filePath);
+ printer.setPageSize(QPageSize(QPageSize::A4));
+ printer.setPageMargins(QMarginsF(10, 10, 10, 10));
+
+ doc.print(&printer);
+}
+
void LLTutorWindow::wrongAnimation() {
if (lastUserMessage == nullptr) {
return;
@@ -898,7 +1026,22 @@ void LLTutorWindow::on_confirmButton_clicked() {
isCorrect = verifyResponseForC();
}
- if (!isCorrect) {
+ if (examMode) {
+ // No feedback in exam mode: record the real result silently and
+ // advance along the correct path so error states never trigger.
+ // Table answers are recorded cell by cell in handleTableSubmission.
+ if (prevState != State::C && prevState != State::C_prime) {
+ examSession.record(currentQuestionText, userResponse,
+ examSolutionText(), isCorrect);
+ if (isCorrect) {
+ ++cntRightAnswers;
+ } else {
+ ++cntWrongAnswers;
+ }
+ }
+ lastUserMessage = nullptr;
+ isCorrect = true;
+ } else if (!isCorrect) {
ui->cntWrong->setText(QString::number(++cntWrongAnswers));
animateLabelPop(ui->cross);
animateLabelColor(ui->cross, QColor::fromRgb(204, 51, 51));
@@ -926,46 +1069,67 @@ void LLTutorWindow::on_confirmButton_clicked() {
if (currentState == State::fin) {
ui->userResponse->setDisabled(true);
ui->confirmButton->setDisabled(true);
- addMessage(tr("Ejercicio terminado. ¿Quieres exportar la conversación "
- "o salir?"),
- false);
auto* actions = new QWidget();
auto* layout = new QHBoxLayout(actions);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(10);
- auto* exportBtn = new QPushButton(tr("Exportar PDF"), actions);
- exportBtn->setObjectName("llTutorExportPdfButton");
- exportBtn->setCursor(Qt::PointingHandCursor);
- exportBtn->setProperty("role", "primary");
+ if (examMode) {
+ addMessage(tr("Examen terminado. Consulta tu informe con la "
+ "calificación y la revisión de tus respuestas."),
+ false);
+
+ auto* reportBtn = new QPushButton(tr("Ver informe"), actions);
+ reportBtn->setObjectName("llTutorExamReportButton");
+ reportBtn->setCursor(Qt::PointingHandCursor);
+ reportBtn->setProperty("role", "primary");
+ layout->addWidget(reportBtn);
+ connect(reportBtn, &QPushButton::clicked, this,
+ &LLTutorWindow::showExamReport);
+ } else {
+ addMessage(tr("Ejercicio terminado. ¿Quieres exportar la "
+ "conversación o salir?"),
+ false);
+
+ auto* exportBtn = new QPushButton(tr("Exportar PDF"), actions);
+ exportBtn->setObjectName("llTutorExportPdfButton");
+ exportBtn->setCursor(Qt::PointingHandCursor);
+ exportBtn->setProperty("role", "primary");
+ layout->addWidget(exportBtn);
+ connect(exportBtn, &QPushButton::clicked, this, [this]() {
+ const QString filePath = promptExportFilePath();
+ if (!filePath.isEmpty()) {
+ exportConversationToPdf(filePath);
+ }
+ });
+ }
auto* exitBtn = new QPushButton(tr("Salir"), actions);
exitBtn->setObjectName("llTutorExitButton");
exitBtn->setCursor(Qt::PointingHandCursor);
exitBtn->setProperty("role", "danger");
-
- layout->addWidget(exportBtn);
layout->addWidget(exitBtn);
-
- connect(exportBtn, &QPushButton::clicked, this, [this]() {
- const QString filePath = promptExportFilePath();
- if (!filePath.isEmpty()) {
- exportConversationToPdf(filePath);
- }
- });
-
connect(exitBtn, &QPushButton::clicked, this,
[this]() { requestExit(true); });
addWidgetMessage(actions);
ui->listWidget->scrollToBottom();
+
+ if (examMode) {
+ showExamReport();
+ }
return;
}
if (isCorrect || stateChanged || isTableState) {
ui->userResponse->clear();
}
- addMessage(generateQuestion(), false);
+ postQuestion();
+}
+
+void LLTutorWindow::postQuestion() {
+ currentQuestionText = generateQuestion();
+ addMessage(currentQuestionText, false);
}
/************************************************************
diff --git a/src/gui/lltutorwindow.h b/src/gui/lltutorwindow.h
index dc15ed1..9d021f2 100644
--- a/src/gui/lltutorwindow.h
+++ b/src/gui/lltutorwindow.h
@@ -43,6 +43,7 @@
#include
#include
+#include "examsession.h"
#include "grammar.hpp"
#include "grammarview.h"
#include "ll1_parser.hpp"
@@ -96,10 +97,12 @@ class LLTutorWindow : public QWidget {
* @param grammar The grammar to use during the session.
* @param tm Optional pointer to the tutorial manager (for help overlays).
* @param parent Parent widget.
+ * @param examMode When true, all feedback is suppressed: answers are
+ * recorded silently, error sub-questions never trigger, and a graded
+ * report is shown at the end.
*/
- explicit LLTutorWindow(const Grammar& grammar,
- TutorialManager* tm = nullptr,
- QWidget* parent = nullptr);
+ explicit LLTutorWindow(const Grammar& grammar, TutorialManager* tm = nullptr,
+ QWidget* parent = nullptr, bool examMode = false);
~LLTutorWindow();
// ====== State Machine & Question Logic ====================
@@ -206,6 +209,14 @@ class LLTutorWindow : public QWidget {
void updatePlaceholder();
bool confirmExitToHome();
QString promptExportFilePath() const;
+
+ // ====== Exam Mode =========================================
+ void postQuestion(); ///< Shows and remembers the next question.
+ QString examSolutionText(); ///< Expected answer for the current state.
+ void scoreExamTable(); ///< Grades the LL table cell by cell.
+ void showExamReport(); ///< Opens the end-of-exam report dialog.
+ void exportExamReportToPdf(const QString& filePath,
+ const QString& html) const;
#ifdef SYNTAXTUTOR_TESTING
public:
QString currentStateForTest() const;
@@ -216,6 +227,9 @@ class LLTutorWindow : public QWidget {
void setAnswerForTest(const QString& text);
void submitForTest();
void setNextExportFilePathForTest(const QString& filePath);
+ double examGradeForTest() const { return examSession.grade(); }
+ int examTotalForTest() const { return examSession.total(); }
+ int examRightForTest() const { return examSession.right(); }
#endif
private slots:
void on_backButton_clicked();
@@ -250,6 +264,11 @@ class LLTutorWindow : public QWidget {
unsigned lltries = 0;
unsigned cntRightAnswers = 0, cntWrongAnswers = 0;
+ // ====== Exam Mode =========================================
+ bool examMode = false;
+ ExamSession examSession;
+ QString currentQuestionText;
+
using Cell = std::pair;
std::vector lastWrongCells;
LLTableDialog* currentDlg = nullptr;
diff --git a/src/gui/mainwindow.cpp b/src/gui/mainwindow.cpp
index 3032d82..a98cf93 100644
--- a/src/gui/mainwindow.cpp
+++ b/src/gui/mainwindow.cpp
@@ -332,6 +332,7 @@ void MainWindow::setNavigationEnabled(bool enabled) {
ui->lv2Button->setEnabled(levelsEnabled);
ui->lv3Button->setEnabled(levelsEnabled);
ui->customGrammarCheck->setEnabled(enabled);
+ ui->examModeCheck->setEnabled(enabled);
}
void MainWindow::showHomePage() {
@@ -360,13 +361,14 @@ void MainWindow::cleanupTutorPages() {
}
LLTutorWindow* MainWindow::startLLTutor(const Grammar& grammar,
- TutorialManager* tutorialManager) {
+ TutorialManager* tutorialManager,
+ bool examMode) {
if (llTutorPage) {
stack->removeWidget(llTutorPage);
llTutorPage->deleteLater();
}
- llTutorPage = new LLTutorWindow(grammar, tutorialManager, stack);
+ llTutorPage = new LLTutorWindow(grammar, tutorialManager, stack, examMode);
stack->addWidget(llTutorPage);
stack->setCurrentWidget(llTutorPage);
setWindowTitle(tr("LL(1)"));
@@ -395,13 +397,15 @@ LLTutorWindow* MainWindow::startLLTutor(const Grammar& grammar,
}
SLRTutorWindow* MainWindow::startSLRTutor(const Grammar& grammar,
- TutorialManager* tutorialManager) {
+ TutorialManager* tutorialManager,
+ bool examMode) {
if (slrTutorPage) {
stack->removeWidget(slrTutorPage);
slrTutorPage->deleteLater();
}
- slrTutorPage = new SLRTutorWindow(grammar, tutorialManager, stack);
+ slrTutorPage =
+ new SLRTutorWindow(grammar, tutorialManager, stack, examMode);
stack->addWidget(slrTutorPage);
stack->setCurrentWidget(slrTutorPage);
setWindowTitle(tr("SLR(1)"));
@@ -480,29 +484,31 @@ void MainWindow::on_customGrammarCheck_toggled(bool checked) {
}
void MainWindow::on_pushButton_clicked() {
+ const bool examMode = ui->examModeCheck->isChecked();
if (ui->customGrammarCheck->isChecked()) {
GrammarEditorDialog dialog(GrammarEditorDialog::Mode::LL1, this);
if (dialog.exec() != QDialog::Accepted) {
return;
}
- startLLTutor(dialog.grammar(), nullptr);
+ startLLTutor(dialog.grammar(), nullptr, examMode);
return;
}
Grammar grammar = factory.GenLL1Grammar(level);
- startLLTutor(grammar, nullptr);
+ startLLTutor(grammar, nullptr, examMode);
}
void MainWindow::on_pushButton_2_clicked() {
+ const bool examMode = ui->examModeCheck->isChecked();
if (ui->customGrammarCheck->isChecked()) {
GrammarEditorDialog dialog(GrammarEditorDialog::Mode::SLR1, this);
if (dialog.exec() != QDialog::Accepted) {
return;
}
- startSLRTutor(dialog.grammar(), nullptr);
+ startSLRTutor(dialog.grammar(), nullptr, examMode);
return;
}
Grammar grammar = factory.GenSLR1Grammar(level);
- startSLRTutor(grammar, nullptr);
+ startSLRTutor(grammar, nullptr, examMode);
}
void MainWindow::on_tutorial_clicked() {
diff --git a/src/gui/mainwindow.h b/src/gui/mainwindow.h
index 0b0109b..d2cb34e 100644
--- a/src/gui/mainwindow.h
+++ b/src/gui/mainwindow.h
@@ -182,8 +182,10 @@ class MainWindow : public QMainWindow {
void setNavigationEnabled(bool enabled);
void showHomePage();
void cleanupTutorPages();
- LLTutorWindow* startLLTutor(const Grammar& grammar, TutorialManager* tm);
- SLRTutorWindow* startSLRTutor(const Grammar& grammar, TutorialManager* tm);
+ LLTutorWindow* startLLTutor(const Grammar& grammar, TutorialManager* tm,
+ bool examMode = false);
+ SLRTutorWindow* startSLRTutor(const Grammar& grammar, TutorialManager* tm,
+ bool examMode = false);
void abortTutorialFlow();
/**
diff --git a/src/gui/mainwindow.ui b/src/gui/mainwindow.ui
index c106b88..868d494 100644
--- a/src/gui/mainwindow.ui
+++ b/src/gui/mainwindow.ui
@@ -339,6 +339,19 @@
+ -
+
+
+ PointingHandCursor
+
+
+ Modo examen
+
+
+ Sin retroalimentación durante el ejercicio: al terminar verás un informe con tu calificación.
+
+
+
-
diff --git a/src/gui/slrtabledialog.cpp b/src/gui/slrtabledialog.cpp
index 7f8f841..73e512b 100644
--- a/src/gui/slrtabledialog.cpp
+++ b/src/gui/slrtabledialog.cpp
@@ -174,6 +174,10 @@ void SLRTableDialog::setGuidedModeActive(bool active) {
submitButton->setEnabled(!active);
}
+void SLRTableDialog::setGuidedButtonVisible(bool visible) {
+ guidedButton->setVisible(visible);
+}
+
void SLRTableDialog::highlightIncorrectCells(
const QList>& coords) {
for (int r = 0; r < table->rowCount(); ++r) {
diff --git a/src/gui/slrtabledialog.h b/src/gui/slrtabledialog.h
index c66e14d..cf4a557 100644
--- a/src/gui/slrtabledialog.h
+++ b/src/gui/slrtabledialog.h
@@ -72,6 +72,15 @@ class SLRTableDialog : public QDialog {
void setInitialData(const QVector>& data);
void setGuidedModeActive(bool active);
+ /**
+ * @brief Shows or hides the guided mode button.
+ *
+ * Exam mode hides it: the guided walkthrough would reveal the answers.
+ *
+ * @param visible Whether the button should be visible.
+ */
+ void setGuidedButtonVisible(bool visible);
+
void highlightIncorrectCells(const QList>& coords);
void highlightInvalidCells(const QList>& coords);
diff --git a/src/gui/slrtutorwindow.cpp b/src/gui/slrtutorwindow.cpp
index 233ef63..cd7af67 100644
--- a/src/gui/slrtutorwindow.cpp
+++ b/src/gui/slrtutorwindow.cpp
@@ -17,6 +17,7 @@
*/
#include "slrtutorwindow.h"
+#include "examreportdialog.h"
#include "grammarview.h"
#include "slrwizard.h"
#include "tutorialmanager.h"
@@ -191,9 +192,9 @@ bool NormalizeSlrCell(const QString& cell, QString* normalized) {
} // namespace
SLRTutorWindow::SLRTutorWindow(const Grammar& g, TutorialManager* tm,
- QWidget* parent)
+ QWidget* parent, bool examMode)
: QWidget(parent), ui(new Ui::SLRTutorWindow), grammar(g), slr1(g),
- tm(tm) {
+ examMode(examMode), tm(tm) {
// ====== Parser Initialization ============================
slr1.MakeParser();
@@ -273,12 +274,20 @@ SLRTutorWindow::SLRTutorWindow(const Grammar& g, TutorialManager* tm,
ui->cntRight->setText(QString::number(cntRightAnswers));
ui->cntWrong->setText(QString::number(cntWrongAnswers));
+ // In exam mode the live counters would leak feedback; hide them.
+ if (examMode) {
+ ui->tick->hide();
+ ui->cross->hide();
+ ui->cntRight->hide();
+ ui->cntWrong->hide();
+ }
+
updateProgressPanel();
addGrammarMessage();
currentState = StateSlr::A;
updatePlaceholder();
- addMessage(generateQuestion(), false);
+ postQuestion();
// ====== Signal Connections ==================================
connect(ui->userResponse, &CustomTextEdit::sendRequested, this,
@@ -626,6 +635,10 @@ void SLRTutorWindow::showTable() {
});
auto* dialog = new SLRTableDialog(slr1.states_.size(), colHeaders.size(),
colHeaders, this, &rawTable);
+ if (examMode) {
+ // The guided walkthrough reveals the answers; not during an exam.
+ dialog->setGuidedButtonVisible(false);
+ }
connect(dialog, &SLRTableDialog::guidedRequested, this,
[this, dialog, colHeaders](const QVector>& data) {
@@ -862,6 +875,21 @@ void SLRTutorWindow::showTable() {
}
}
+ if (examMode) {
+ // Single submission: grade every cell, no highlights or
+ // retries.
+ scoreExamTable(colHeaders);
+ if (incorrectCoords.isEmpty() && invalidCoords.isEmpty()) {
+ ++cntRightAnswers;
+ } else {
+ ++cntWrongAnswers;
+ }
+ dialog->accept();
+ on_confirmButton_clicked();
+ dialog->deleteLater();
+ return;
+ }
+
dialog->highlightIncorrectCells(incorrectCoords);
dialog->highlightInvalidCells(invalidCoords);
@@ -1369,7 +1397,22 @@ void SLRTutorWindow::on_confirmButton_clicked() {
isCorrect = verifyResponse("");
}
- if (!isCorrect) {
+ if (examMode) {
+ // No feedback in exam mode: record the real result silently and
+ // advance along the correct path so error states never trigger.
+ // Table answers are recorded cell by cell in scoreExamTable.
+ if (prevState != StateSlr::H && prevState != StateSlr::H_prime) {
+ examSession.record(currentQuestionText, userResponse,
+ examSolutionText(), isCorrect);
+ if (isCorrect) {
+ ++cntRightAnswers;
+ } else {
+ ++cntWrongAnswers;
+ }
+ }
+ lastUserMessage = nullptr;
+ isCorrect = true;
+ } else if (!isCorrect) {
ui->cntWrong->setText(QString::number(++cntWrongAnswers));
animateLabelPop(ui->cross);
animateLabelColor(ui->cross, QColor::fromRgb(204, 51, 51));
@@ -1396,48 +1439,69 @@ void SLRTutorWindow::on_confirmButton_clicked() {
if (currentState == StateSlr::fin) {
ui->userResponse->setDisabled(true);
ui->confirmButton->setDisabled(true);
- addMessage(tr("Ejercicio terminado. ¿Quieres exportar la conversación "
- "o salir?"),
- false);
auto* actions = new QWidget();
auto* layout = new QHBoxLayout(actions);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(10);
- auto* exportBtn = new QPushButton(tr("Exportar PDF"), actions);
- exportBtn->setObjectName("slrTutorExportPdfButton");
- exportBtn->setCursor(Qt::PointingHandCursor);
- exportBtn->setProperty("role", "primary");
+ if (examMode) {
+ addMessage(tr("Examen terminado. Consulta tu informe con la "
+ "calificación y la revisión de tus respuestas."),
+ false);
+
+ auto* reportBtn = new QPushButton(tr("Ver informe"), actions);
+ reportBtn->setObjectName("slrTutorExamReportButton");
+ reportBtn->setCursor(Qt::PointingHandCursor);
+ reportBtn->setProperty("role", "primary");
+ layout->addWidget(reportBtn);
+ connect(reportBtn, &QPushButton::clicked, this,
+ &SLRTutorWindow::showExamReport);
+ } else {
+ addMessage(tr("Ejercicio terminado. ¿Quieres exportar la "
+ "conversación o salir?"),
+ false);
+
+ auto* exportBtn = new QPushButton(tr("Exportar PDF"), actions);
+ exportBtn->setObjectName("slrTutorExportPdfButton");
+ exportBtn->setCursor(Qt::PointingHandCursor);
+ exportBtn->setProperty("role", "primary");
+ layout->addWidget(exportBtn);
+ connect(exportBtn, &QPushButton::clicked, this, [this]() {
+ const QString filePath = promptExportFilePath();
+ if (!filePath.isEmpty()) {
+ exportConversationToPdf(filePath);
+ }
+ });
+ }
auto* exitBtn = new QPushButton(tr("Salir"), actions);
exitBtn->setObjectName("slrTutorExitButton");
exitBtn->setCursor(Qt::PointingHandCursor);
exitBtn->setProperty("role", "danger");
-
- layout->addWidget(exportBtn);
layout->addWidget(exitBtn);
-
- connect(exportBtn, &QPushButton::clicked, this, [this]() {
- const QString filePath = promptExportFilePath();
- if (!filePath.isEmpty()) {
- exportConversationToPdf(filePath);
- }
- });
-
connect(exitBtn, &QPushButton::clicked, this,
[this]() { requestExit(true); });
addWidgetMessage(actions);
ui->listWidget->scrollToBottom();
+
+ if (examMode) {
+ showExamReport();
+ }
return;
}
- addMessage(generateQuestion(), false);
+ postQuestion();
if (isCorrect || stateChanged || isTableState) {
ui->userResponse->clear();
}
}
+void SLRTutorWindow::postQuestion() {
+ currentQuestionText = generateQuestion();
+ addMessage(currentQuestionText, false);
+}
+
/************************************************************
* QUESTION GENERATION (SLR) *
* Returns a context-sensitive question string based on
@@ -3160,6 +3224,172 @@ SLRTutorWindow::ingestUserRules(const QString& userResponse) {
return rules;
}
+QString SLRTutorWindow::examSolutionText() {
+ auto joinSortedStrings = [](const QSet& set) {
+ QStringList values = set.values();
+ std::sort(values.begin(), values.end());
+ return values.join(", ");
+ };
+ auto joinSortedIds = [](const QSet& set) {
+ QList values = set.values();
+ std::sort(values.begin(), values.end());
+ QStringList texts;
+ for (unsigned id : values) {
+ texts.append(QString::number(id));
+ }
+ return texts.join(", ");
+ };
+
+ switch (currentState) {
+ case StateSlr::A:
+ case StateSlr::A4:
+ case StateSlr::A_prime:
+ return QString::fromStdString(slr1.PrintItems(solutionForA()));
+ case StateSlr::A1:
+ return solutionForA1();
+ case StateSlr::A2:
+ return solutionForA2();
+ case StateSlr::A3: {
+ QStringList rules;
+ for (const auto& [antecedent, consequent] : solutionForA3()) {
+ rules.append(QString("%1 -> %2").arg(
+ QString::fromStdString(antecedent),
+ QStringList::fromVector(stdVectorToQVector(consequent))
+ .join(' ')));
+ }
+ return rules.join('\n');
+ }
+ case StateSlr::B:
+ return QString::number(solutionForB());
+ case StateSlr::C:
+ return QString::number(solutionForC());
+ case StateSlr::CA:
+ return solutionForCA().join(", ");
+ case StateSlr::CB:
+ return QString::fromStdString(slr1.PrintItems(solutionForCB()));
+ case StateSlr::D:
+ return solutionForD().join(',');
+ case StateSlr::D1:
+ return solutionForD1();
+ case StateSlr::D2:
+ return solutionForD2();
+ case StateSlr::E:
+ return QString::number(solutionForE());
+ case StateSlr::E1:
+ return joinSortedIds(solutionForE1());
+ case StateSlr::E2: {
+ const QMap counts = solutionForE2();
+ QStringList pairs;
+ for (auto it = counts.cbegin(); it != counts.cend(); ++it) {
+ pairs.append(QString("%1:%2").arg(it.key()).arg(it.value()));
+ }
+ return pairs.join(", ");
+ }
+ case StateSlr::F:
+ return joinSortedIds(solutionForF());
+ case StateSlr::FA:
+ return joinSortedStrings(solutionForFA());
+ case StateSlr::G:
+ return joinSortedStrings(solutionForG());
+ default:
+ return {};
+ }
+}
+
+void SLRTutorWindow::scoreExamTable(const QStringList& colHeaders) {
+ const QString emptyCell = tr("(vacía)");
+ const int nTerm = slr1.gr_.st_.terminals_.size();
+
+ for (int stateId = 0; stateId < rawTable.size(); ++stateId) {
+ for (int col = 0; col < colHeaders.size(); ++col) {
+ const QString sym = colHeaders[col];
+ QString expectedText;
+
+ if (col < nTerm) {
+ const auto& actMap = slr1.actions_.at(stateId);
+ auto itAct = actMap.find(sym.toStdString());
+ if (itAct != actMap.end()) {
+ switch (itAct->second.action) {
+ case SLR1Parser::Action::Shift:
+ expectedText = QString("s%1").arg(
+ slr1.transitions_.at(stateId).at(
+ sym.toStdString()));
+ break;
+ case SLR1Parser::Action::Reduce: {
+ const Lr0Item* item = itAct->second.item;
+ int prodIdx = -1;
+ for (int k = 0; k < sortedGrammar.size(); ++k) {
+ const auto& rule = sortedGrammar[k];
+ if (rule.first.toStdString() ==
+ item->antecedent_ &&
+ stdVectorToQVector(item->consequent_) ==
+ rule.second) {
+ prodIdx = k;
+ break;
+ }
+ }
+ expectedText = QString("r%1").arg(prodIdx);
+ break;
+ }
+ case SLR1Parser::Action::Accept:
+ expectedText = QStringLiteral("acc");
+ break;
+ default:
+ break;
+ }
+ }
+ } else if (slr1.transitions_.contains(stateId)) {
+ const auto& transMap = slr1.transitions_.at(stateId);
+ auto itTrans = transMap.find(sym.toStdString());
+ if (itTrans != transMap.end()) {
+ expectedText = QString::number(itTrans->second);
+ }
+ }
+
+ const QString userText = rawTable[stateId][col].trimmed();
+ if (expectedText.isEmpty() && userText.isEmpty()) {
+ continue;
+ }
+
+ const bool correct =
+ QString::compare(userText, expectedText,
+ Qt::CaseInsensitive) == 0;
+ examSession.record(
+ tr("Tabla SLR(1): celda (I%1, %2)").arg(stateId).arg(sym),
+ userText.isEmpty() ? emptyCell : userText,
+ expectedText.isEmpty() ? emptyCell : expectedText, correct);
+ }
+ }
+}
+
+void SLRTutorWindow::showExamReport() {
+ auto* report = new ExamReportDialog(examSession, tr("Examen SLR(1)"), this);
+ report->setAttribute(Qt::WA_DeleteOnClose);
+ report->setWindowModality(Qt::WindowModal);
+ connect(report, &ExamReportDialog::exportRequested, this,
+ [this, report]() {
+ const QString filePath = promptExportFilePath();
+ if (!filePath.isEmpty()) {
+ exportExamReportToPdf(filePath, report->reportHtml());
+ }
+ });
+ report->show();
+}
+
+void SLRTutorWindow::exportExamReportToPdf(const QString& filePath,
+ const QString& html) const {
+ QTextDocument doc;
+ doc.setHtml(html);
+
+ QPrinter printer(QPrinter::HighResolution);
+ printer.setOutputFormat(QPrinter::PdfFormat);
+ printer.setOutputFileName(filePath);
+ printer.setPageSize(QPageSize(QPageSize::A4));
+ printer.setPageMargins(QMarginsF(10, 10, 10, 10));
+
+ doc.print(&printer);
+}
+
QString SLRTutorWindow::FormatGrammar(const Grammar& grammar) {
QString result;
int ruleCount = 0;
diff --git a/src/gui/slrtutorwindow.h b/src/gui/slrtutorwindow.h
index 237b34e..9adbcc3 100644
--- a/src/gui/slrtutorwindow.h
+++ b/src/gui/slrtutorwindow.h
@@ -20,6 +20,7 @@
#define SLRTUTORWINDOW_H
#include "UniqueQueue.h"
+#include "examsession.h"
#include "grammar.hpp"
#include "grammarview.h"
#include "slr1_parser.hpp"
@@ -102,9 +103,12 @@ class SLRTutorWindow : public QWidget {
* @param g The grammar used for the session.
* @param tm Optional pointer to the tutorial manager (for guided tour).
* @param parent Parent widget.
+ * @param examMode When true, all feedback is suppressed: answers are
+ * recorded silently, error sub-questions never trigger, and a graded
+ * report is shown at the end.
*/
explicit SLRTutorWindow(const Grammar& g, TutorialManager* tm = nullptr,
- QWidget* parent = nullptr);
+ QWidget* parent = nullptr, bool examMode = false);
~SLRTutorWindow();
// ====== Core Flow Control =====================================
@@ -223,6 +227,15 @@ class SLRTutorWindow : public QWidget {
void updatePlaceholder();
bool confirmExitToHome();
QString promptExportFilePath() const;
+
+ // ====== Exam Mode =============================================
+ void postQuestion(); ///< Shows and remembers the next question.
+ QString examSolutionText(); ///< Expected answer for the current state.
+ void scoreExamTable(
+ const QStringList& colHeaders); ///< Grades the SLR table per cell.
+ void showExamReport(); ///< Opens the end-of-exam report.
+ void exportExamReportToPdf(const QString& filePath,
+ const QString& html) const;
#ifdef SYNTAXTUTOR_TESTING
public:
QString currentStateForTest() const;
@@ -233,6 +246,9 @@ class SLRTutorWindow : public QWidget {
unsigned currentStateIdForTest() const;
QString currentCbSymbolForTest() const;
void setNextExportFilePathForTest(const QString& filePath);
+ double examGradeForTest() const { return examSession.grade(); }
+ int examTotalForTest() const { return examSession.total(); }
+ int examRightForTest() const { return examSession.right(); }
#endif
private slots:
void on_backButton_clicked();
@@ -281,6 +297,11 @@ class SLRTutorWindow : public QWidget {
unsigned cntRightAnswers = 0;
unsigned cntWrongAnswers = 0;
+ // ====== Exam Mode ==============================================
+ bool examMode = false;
+ ExamSession examSession;
+ QString currentQuestionText;
+
// ====== State Machine Runtime Variables ========================
std::unordered_set userMadeStates; // All states the user has created
std::unordered_map>
From ed7fded03b7ca565c3d205c48aab94464f20dbb4 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Fri, 12 Jun 2026 18:45:23 +0200
Subject: [PATCH 4/5] test: add tests
---
resources/styles/app.qss | 94 +++++++++++++++++++++-
tests/tests.pro | 3 +
tests/tutor/ll_tutor_window_test.cpp | 111 ++++++++++++++++++++++++++
tests/tutor/main_window_test.cpp | 43 ++++++++++
tests/tutor/slr_tutor_window_test.cpp | 70 ++++++++++++++++
tests/tutor/tutor_window_test.h | 4 +
6 files changed, 321 insertions(+), 4 deletions(-)
diff --git a/resources/styles/app.qss b/resources/styles/app.qss
index 16f92c6..10ec484 100644
--- a/resources/styles/app.qss
+++ b/resources/styles/app.qss
@@ -721,13 +721,15 @@ QDialog[grammarEditor="true"] QPushButton#grammarEditorStartButton:disabled {
border: 1px solid #353D40;
}
-QCheckBox#customGrammarCheck {
+QCheckBox#customGrammarCheck,
+QCheckBox#examModeCheck {
color: #C9D0D2;
font-size: 13px;
spacing: 8px;
}
-QCheckBox#customGrammarCheck::indicator {
+QCheckBox#customGrammarCheck::indicator,
+QCheckBox#examModeCheck::indicator {
width: 16px;
height: 16px;
border-radius: 4px;
@@ -735,12 +737,96 @@ QCheckBox#customGrammarCheck::indicator {
background-color: #2A2E30;
}
-QCheckBox#customGrammarCheck::indicator:hover {
+QCheckBox#customGrammarCheck::indicator:hover,
+QCheckBox#examModeCheck::indicator:hover {
border: 1px solid #11B3BC;
}
-QCheckBox#customGrammarCheck::indicator:checked {
+QCheckBox#customGrammarCheck::indicator:checked,
+QCheckBox#examModeCheck::indicator:checked {
background-color: #11B3BC;
border: 1px solid #11B3BC;
image: url(:/resources/check.svg);
}
+
+QDialog[examReport="true"] {
+ background-color: #1F1F1F;
+ border: none;
+}
+
+QDialog[examReport="true"] QLabel#examReportEyebrow {
+ color: #11B3BC;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+QDialog[examReport="true"] QLabel#examReportTitle {
+ color: #F4F7F8;
+ font-size: 28px;
+ font-weight: 700;
+}
+
+QDialog[examReport="true"] QLabel#examReportGrade {
+ font-size: 56px;
+ font-weight: 800;
+ color: #11B3BC;
+}
+
+QDialog[examReport="true"] QLabel#examReportGrade[passed="false"] {
+ color: #E0635F;
+}
+
+QDialog[examReport="true"] QLabel#examReportGradeCaption {
+ color: #9AA5A8;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+QDialog[examReport="true"] QLabel#examReportStats {
+ color: #DEE5E7;
+ font-size: 15px;
+}
+
+QDialog[examReport="true"] QLabel#examReportVerdict {
+ color: #9FE3E7;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+QDialog[examReport="true"] QLabel#examReportVerdict[passed="false"] {
+ color: #F1B0AE;
+}
+
+QDialog[examReport="true"] QLabel#examReportReviewCaption {
+ color: #9FCFD2;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
+QDialog[examReport="true"] QTextBrowser#examReportReview {
+ background-color: #212526;
+ color: #E8ECEE;
+ border: 1px solid #303638;
+ border-radius: 12px;
+ padding: 10px 12px;
+ selection-background-color: #35515A;
+}
+
+QDialog[examReport="true"] QPushButton#examReportCloseButton {
+ background: transparent;
+ color: #AAB5B8;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+QDialog[examReport="true"] QPushButton#examReportCloseButton:hover {
+ background-color: rgba(255, 255, 255, 0.03);
+ color: #FFFFFF;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
diff --git a/tests/tests.pro b/tests/tests.pro
index d14a11a..7fe54f3 100644
--- a/tests/tests.pro
+++ b/tests/tests.pro
@@ -33,6 +33,7 @@ SOURCES += \
../src/widgets/customtextedit.cpp \
../src/widgets/grammarview.cpp \
../src/widgets/tutorialmanager.cpp \
+ ../src/gui/examreportdialog.cpp \
../src/gui/grammareditordialog.cpp \
../src/gui/lltabledialog.cpp \
../src/gui/lltutorwindow.cpp \
@@ -57,6 +58,8 @@ HEADERS += \
../src/widgets/customtextedit.h \
../src/widgets/grammarview.h \
../src/widgets/tutorialmanager.h \
+ ../src/gui/examreportdialog.h \
+ ../src/gui/examsession.h \
../src/gui/grammareditordialog.h \
../src/gui/lltabledialog.h \
../src/gui/lltutorwindow.h \
diff --git a/tests/tutor/ll_tutor_window_test.cpp b/tests/tutor/ll_tutor_window_test.cpp
index 4270127..455ebfa 100644
--- a/tests/tutor/ll_tutor_window_test.cpp
+++ b/tests/tutor/ll_tutor_window_test.cpp
@@ -1,5 +1,6 @@
#include "tutor_window_test.h"
+#include "examreportdialog.h"
#include "ll1_tutor_test_utils.h"
#include "lltutorwindow.h"
#include "qt_modal_test_utils.h"
@@ -10,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -761,3 +763,112 @@ void TutorWindowTest::llAcceptsFlexibleTableCellFormatting() {
QTRY_COMPARE(tutor.currentStateForTest(), QString("fin"));
});
}
+
+// -----------------------------------------------------------------------------
+// Case: LL1-TC-17
+// Summary:
+// Verifies exam mode: wrong answers never branch into error states, no
+// feedback counters are visible, and an all-wrong exam grades 0 with the
+// report dialog shown at the end.
+//
+// Situation:
+// LL(1) tutor created in exam mode on the simple fixture.
+//
+// Action:
+// The user answers every question and the final table incorrectly.
+//
+// Expected:
+// The state machine walks A -> B -> C -> fin without visiting A1/B1/B2,
+// the exam session records every item as wrong, the grade is 0, and the
+// exam report dialog opens.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::llExamModeWrongAnswersFollowMainPathAndGradeZero() {
+ const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar();
+ LLTutorWindow tutor(grammar, nullptr, nullptr, true);
+
+ QVERIFY(tutor.findChild("cntRight")->isHidden());
+ QVERIFY(tutor.findChild("cntWrong")->isHidden());
+ QVERIFY(tutor.findChild("tick")->isHidden());
+ QVERIFY(tutor.findChild("cross")->isHidden());
+
+ // A wrong answer advances to B directly: no A1 sub-question in exam mode.
+ tutor.setAnswerForTest("999,999");
+ tutor.submitForTest();
+ QCOMPARE(tutor.currentStateForTest(), QString("B"));
+ QCOMPARE(tutor.wrongCountForTest(), 1);
+
+ int guard = 0;
+ while (tutor.currentStateForTest() == "B" && ++guard < 50) {
+ tutor.setAnswerForTest("zz");
+ tutor.submitForTest();
+ QVERIFY(tutor.currentStateForTest() == "B" ||
+ tutor.currentStateForTest() == "C");
+ }
+ QCOMPARE(tutor.currentStateForTest(), QString("C"));
+
+ LLTableDialog* dialog = waitForTableDialog();
+ auto* table = dialog->findChild("llTableWidget");
+ QVERIFY(table != nullptr);
+ QtModalTestUtils::submitLlTableDialog(
+ dialog, QVector>(
+ table->rowCount(), QVector(table->columnCount())));
+
+ QTRY_COMPARE(tutor.currentStateForTest(), QString("fin"));
+ QVERIFY(tutor.examTotalForTest() > 0);
+ QCOMPARE(tutor.examRightForTest(), 0);
+ QCOMPARE(tutor.examGradeForTest(), 0.0);
+
+ auto* report =
+ QtModalTestUtils::waitForVisibleTopLevelWidget();
+ QVERIFY(report != nullptr);
+ auto* closeButton = report->findChild("examReportCloseButton");
+ QVERIFY(closeButton != nullptr);
+ QTest::mouseClick(closeButton, Qt::LeftButton);
+}
+
+// -----------------------------------------------------------------------------
+// Case: LL1-TC-18
+// Summary:
+// Verifies that a perfect exam scores 10 and counts every question plus
+// every expected table cell.
+//
+// Situation:
+// LL(1) tutor created in exam mode on each fixture.
+//
+// Action:
+// The user answers every question and the final table correctly.
+//
+// Expected:
+// The exam ends with grade 10.0 and right == total, and the report opens.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::llExamModeAllCorrectScoresTen() {
+ forEachLlFixture([](const auto& fixture) {
+ const Grammar grammar = fixture.grammar;
+ LLTutorWindow tutor(grammar, nullptr, nullptr, true);
+
+ runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B",
+ 1, 0}});
+ answerRemainingRulesUntilStateC(tutor, grammar);
+ QCOMPARE(tutor.currentStateForTest(), QString("C"));
+
+ LLTableDialog* dialog = waitForTableDialog();
+ auto* table = dialog->findChild("llTableWidget");
+ QVERIFY(table != nullptr);
+ QtModalTestUtils::submitLlTableDialog(
+ dialog, QtModalTestUtils::buildExpectedTable(grammar, table));
+
+ QTRY_COMPARE(tutor.currentStateForTest(), QString("fin"));
+ QVERIFY(tutor.examTotalForTest() > 0);
+ QCOMPARE(tutor.examRightForTest(), tutor.examTotalForTest());
+ QCOMPARE(tutor.examGradeForTest(), 10.0);
+
+ auto* report =
+ QtModalTestUtils::waitForVisibleTopLevelWidget();
+ QVERIFY(report != nullptr);
+ auto* closeButton =
+ report->findChild("examReportCloseButton");
+ QVERIFY(closeButton != nullptr);
+ QTest::mouseClick(closeButton, Qt::LeftButton);
+ QTest::qWait(20);
+ });
+}
diff --git a/tests/tutor/main_window_test.cpp b/tests/tutor/main_window_test.cpp
index 58da708..19c69de 100644
--- a/tests/tutor/main_window_test.cpp
+++ b/tests/tutor/main_window_test.cpp
@@ -755,3 +755,46 @@ void TutorWindowTest::mainCustomGrammarEditorRestoresLastGrammar() {
grammarText);
QVERIFY(reopened.isGrammarValidForTest());
}
+
+// -----------------------------------------------------------------------------
+// Case: MAIN-TC-19
+// Summary:
+// Verifies that the "Modo examen" checkbox launches tutors in exam mode.
+//
+// Situation:
+// Main window freshly opened on the home screen.
+//
+// Action:
+// The test checks the exam mode checkbox and opens the LL(1) tutor.
+//
+// Expected:
+// The tutor starts with the feedback counters hidden (exam mode active);
+// without the checkbox the counters are visible.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::mainExamModeCheckboxLaunchesExamTutor() {
+ clearTestAppSettings();
+
+ MainWindow window;
+ window.show();
+
+ auto* examCheck = window.findChild("examModeCheck");
+ QVERIFY(examCheck != nullptr);
+ QVERIFY(examCheck->isEnabled());
+ examCheck->setChecked(true);
+
+ QTest::mouseClick(window.findChild("pushButton"),
+ Qt::LeftButton);
+ LLTutorWindow* tutor = nullptr;
+ QTRY_VERIFY((tutor = window.findChild()) != nullptr);
+ QVERIFY(tutor->findChild("cntRight")->isHidden());
+ QVERIFY(tutor->findChild("cntWrong")->isHidden());
+
+ MainWindow normalWindow;
+ normalWindow.show();
+ QTest::mouseClick(normalWindow.findChild("pushButton"),
+ Qt::LeftButton);
+ LLTutorWindow* normalTutor = nullptr;
+ QTRY_VERIFY((normalTutor = normalWindow.findChild()) !=
+ nullptr);
+ QVERIFY(!normalTutor->findChild("cntRight")->isHidden());
+}
diff --git a/tests/tutor/slr_tutor_window_test.cpp b/tests/tutor/slr_tutor_window_test.cpp
index 9c36861..3f06642 100644
--- a/tests/tutor/slr_tutor_window_test.cpp
+++ b/tests/tutor/slr_tutor_window_test.cpp
@@ -1,5 +1,6 @@
#include "tutor_window_test.h"
+#include "examreportdialog.h"
#include "qt_modal_test_utils.h"
#include "slr_tutor_test_utils.h"
#include "slrtutorwindow.h"
@@ -1035,3 +1036,72 @@ void TutorWindowTest::slrAcceptsFlexibleTableCellFormatting() {
QTRY_COMPARE(tutor.currentStateForTest(), QString("fin"));
});
}
+
+// -----------------------------------------------------------------------------
+// Case: SLR1-TC-21
+// Summary:
+// Verifies SLR exam mode: wrong answers never branch into error states or
+// retry loops, the guided mode button is hidden in the table dialog, and
+// the exam report appears at the end with a partial grade.
+//
+// Situation:
+// SLR(1) tutor created in exam mode on the simple fixture.
+//
+// Action:
+// The user answers every chat question with garbage, then submits the
+// correct final table.
+//
+// Expected:
+// The state machine only visits main-path states (no A1..A4, E1/E2 or
+// in-place retries), the table cells score full marks while the chat
+// questions score zero, and the report dialog opens with a grade strictly
+// between 0 and 10.
+// -----------------------------------------------------------------------------
+void TutorWindowTest::slrExamModeWrongAnswersFollowMainPathAndShowReport() {
+ const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar();
+ SLRTutorWindow tutor(grammar, nullptr, nullptr, true);
+
+ QVERIFY(tutor.findChild("cntRight")->isHidden());
+ QVERIFY(tutor.findChild("cntWrong")->isHidden());
+
+ const QSet mainPathStates = {"A", "B", "C", "CA", "CB", "D",
+ "E", "F", "FA", "G", "H", "fin"};
+
+ // With garbage answers the exam must still walk only main-path states:
+ // error sub-states or in-place retries would loop here forever, so the
+ // guard doubles as the no-retry assertion.
+ int guard = 0;
+ while (tutor.currentStateForTest() != "H" && ++guard < 200) {
+ QVERIFY2(mainPathStates.contains(tutor.currentStateForTest()),
+ qPrintable(QString("unexpected state %1")
+ .arg(tutor.currentStateForTest())));
+ tutor.setAnswerForTest("zz");
+ tutor.submitForTest();
+ }
+ QCOMPARE(tutor.currentStateForTest(), QString("H"));
+
+ SLRTableDialog* dialog = waitForSlrTableDialog();
+ QVERIFY(dialog != nullptr);
+ auto* guidedButton = dialog->findChild("slrTableGuidedButton");
+ QVERIFY(guidedButton != nullptr);
+ QVERIFY(!guidedButton->isVisible());
+
+ auto* table = dialog->findChild("slrTableWidget");
+ QVERIFY(table != nullptr);
+ QtModalTestUtils::submitSlrTableDialog(
+ dialog, SlrTutorTestUtils::buildExpectedTable(grammar, table));
+
+ QTRY_COMPARE(tutor.currentStateForTest(), QString("fin"));
+ QVERIFY(tutor.examTotalForTest() > 0);
+ QVERIFY(tutor.examRightForTest() > 0);
+ QVERIFY(tutor.examRightForTest() < tutor.examTotalForTest());
+ QVERIFY(tutor.examGradeForTest() > 0.0);
+ QVERIFY(tutor.examGradeForTest() < 10.0);
+
+ auto* report =
+ QtModalTestUtils::waitForVisibleTopLevelWidget();
+ QVERIFY(report != nullptr);
+ auto* closeButton = report->findChild("examReportCloseButton");
+ QVERIFY(closeButton != nullptr);
+ QTest::mouseClick(closeButton, Qt::LeftButton);
+}
diff --git a/tests/tutor/tutor_window_test.h b/tests/tutor/tutor_window_test.h
index a826991..d8ffee3 100644
--- a/tests/tutor/tutor_window_test.h
+++ b/tests/tutor/tutor_window_test.h
@@ -22,6 +22,8 @@ class TutorWindowTest : public QObject {
void smokeGuiFindsCoreWidgets();
void llAcceptsFlexibleUserFormatting();
void llAcceptsFlexibleTableCellFormatting();
+ void llExamModeWrongAnswersFollowMainPathAndGradeZero();
+ void llExamModeAllCorrectScoresTen();
void slrCreatesTutorWithNullTutorialManager();
void slrStateAErrorPathAdvancesThroughAprime();
@@ -44,6 +46,7 @@ class TutorWindowTest : public QObject {
void slrSmokeGuiFindsCoreWidgets();
void slrAcceptsFlexibleUserFormatting();
void slrAcceptsFlexibleTableCellFormatting();
+ void slrExamModeWrongAnswersFollowMainPathAndShowReport();
void mainInitialUiIsVisibleAndEnabled();
void mainSwitchLanguageToEnglishPersistsSelection();
@@ -61,4 +64,5 @@ class TutorWindowTest : public QObject {
void mainCustomGrammarEditorRejectsInvalidAndNonLl1Grammars();
void mainCustomGrammarEditorCancelKeepsHomePage();
void mainCustomGrammarEditorRestoresLastGrammar();
+ void mainExamModeCheckboxLaunchesExamTutor();
};
From 91ce77b0a8483d87659e3bf5472e5788e54f32b9 Mon Sep 17 00:00:00 2001
From: jose-rZM <100773386+jose-rZM@users.noreply.github.com>
Date: Fri, 12 Jun 2026 18:48:25 +0200
Subject: [PATCH 5/5] chore: add UI tests to workflow
---
.github/workflows/sonarcloud.yml | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index 1b811d5..22e3ea9 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -29,7 +29,7 @@ jobs:
- name: Build backend tests with coverage
run: |
sudo apt-get update
- sudo apt-get install -y libgtest-dev cmake
+ sudo apt-get install -y libgtest-dev cmake qt6-base-dev libqt6svg6-dev
cd /usr/src/gtest && sudo cmake . && sudo make && sudo cp lib/*.a /usr/lib
cd $GITHUB_WORKSPACE
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} \
@@ -49,6 +49,18 @@ jobs:
- name: Run tests and dump XML
run: ./test_runner
+ - name: Build Qt tests
+ working-directory: tests
+ run: |
+ qmake6 tests.pro
+ make -j"$(nproc)"
+
+ - name: Run Qt tests
+ working-directory: tests
+ env:
+ QT_QPA_PLATFORM: offscreen
+ run: ./.bin/tutor_tests
+
- name: Install gcovr & generate SonarQube XML coverage
run: |
sudo apt-get update
@@ -65,4 +77,3 @@ jobs:
--define sonar.cfamily.build-wrapper-output=${{ env.BUILD_WRAPPER_OUT_DIR }}
--define sonar.coverageReportPaths=coverage.xml
--define sonar.qualitygate.breakBuild=false
-
|