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 - 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 \ 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/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("" + "" + "" + "" + "" + "") + .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("" + "" + "" + "" + "" + "") + .arg(rowColor, escaped(record.question), + record.userAnswer.trimmed().isEmpty() + ? tr("(vacía)") + : escaped(record.userAnswer), + escaped(record.correctAnswer), mark); + ++index; + } + html += QStringLiteral("
%1%2%3
%2%3%4%5
"); + 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 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> 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(); };