From 4a477e23e853379bc44b8e365071a8e11c1fc78c Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:13:34 +0200 Subject: [PATCH 01/12] test: update test scripts --- qa/test-cases/ll1.md | 14 +++++++++----- qa/test-cases/main.md | 2 +- qa/test-cases/slr1.md | 21 ++++++++++----------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/qa/test-cases/ll1.md b/qa/test-cases/ll1.md index 10d38c71..9a6ad266 100644 --- a/qa/test-cases/ll1.md +++ b/qa/test-cases/ll1.md @@ -104,17 +104,21 @@ Notes: --- -### LL1-TC-07 - State C table correct path + PDF export (Yes/No) +### LL1-TC-07 - State C table correct path + PDF export Preconditions: - Reached State C (table input dialog opens). Steps: | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | Fill the LL(1) table correctly and click `Finalizar`. | Table is accepted; tutor reaches final state; export prompt appears. | -| 2 | Select `Yes` to export, choose a path, and confirm. | PDF is created at the selected path and is non-empty. | -| 3 | Start a new LL(1) session, reach final state again. | Export prompt appears again. | -| 4 | Select `No` to export. | Tutor closes without creating a new PDF. | +| 1 | Fill the LL(1) table correctly and click `Finalizar`. | Table is accepted; tutor reaches final state; final action buttons appear. | +| 2 | Click `Exportar PDF`, choose a path, and confirm. | PDF is created at the selected path and is non-empty. | +| 3 | Start a new LL(1) session, reach final state again. | Final action buttons appear again. | +| 4 | Click `Salir`. | Tutor closes without creating a new PDF. | + +Notes: +- The current UI does not use a `Yes/No` export confirmation dialog. +- The functional equivalents are `Exportar PDF` (export) and `Salir` (finish without exporting). --- diff --git a/qa/test-cases/main.md b/qa/test-cases/main.md index 06ac21c0..7cb6e4cf 100644 --- a/qa/test-cases/main.md +++ b/qa/test-cases/main.md @@ -138,7 +138,7 @@ Preconditions: Steps: | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | Select `Yes` to export. | File picker opens. | +| 1 | Click `Exportar PDF`. | File picker opens. | | 2 | Choose a path and confirm. | PDF is created at the selected path. | | 3 | Open the PDF. | PDF is non-empty and contains the expected conversation/summary. | diff --git a/qa/test-cases/slr1.md b/qa/test-cases/slr1.md index dcc488bc..42b629d2 100644 --- a/qa/test-cases/slr1.md +++ b/qa/test-cases/slr1.md @@ -266,32 +266,31 @@ Steps: --- -### SLR1-TC-18 - State H table incorrect -> H' wizard, cancel flows +### SLR1-TC-18 - State H incorrect table + guided mode Preconditions: - SLR(1) tutor at State H (table dialog opens). Steps: | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | Fill the table incorrectly and click `Finalizar`. | Wrong counter increments; State H' wizard opens. | -| 2 | In the wizard, click `Cancel`. | Confirmation dialog appears. | -| 3 | Select `No`. | Wizard closes; tutor returns to State H and the table dialog opens again. | -| 4 | In the wizard again, click `Cancel` and select `Yes`. | Tutor window closes. | +| 1 | Fill the table incorrectly and click `Finalizar`. | Incorrect cells are highlighted in red; an information message is shown; the table dialog stays open in State H. | +| 2 | Click `Modo guiado`. | The guided wizard opens. | +| 3 | Complete the wizard and click `Finish`. | The wizard closes and the table dialog remains available in State H. | --- -### SLR1-TC-19 - State H correct path + PDF export (Yes/No) +### SLR1-TC-19 - State H correct path + PDF export Preconditions: - Start a new SLR(1) session and reach State H. Steps: | Step | Action | Expected Result | |------|--------|-----------------| -| 1 | Fill the table incorrectly and click `Finalizar`. | Wrong counter increments; State H' wizard opens. | -| 2 | Complete the wizard and click `Finish`. | Tutor returns to State H and the table dialog opens again. | -| 3 | Fill the table correctly and click `Finalizar`. | Tutor reaches final state; export prompt appears. | -| 4 | Select `Yes`, choose a path, and confirm. | PDF is created at the selected path and is non-empty. | -| 5 | Start a new SLR(1) session, reach final state again, and select `No`. | Tutor closes without creating a new PDF. | +| 1 | Optionally click `Modo guiado` and complete the wizard. | The wizard closes and the table dialog remains available in State H. | +| 2 | Fill the table correctly and click `Finalizar`. | Tutor reaches final state; final action buttons appear. | +| 3 | Click `Exportar PDF`, choose a path, and confirm. | PDF is created at the selected path and is non-empty. | +| 4 | Start a new SLR(1) session, reach final state again, and click `Salir`. | Tutor closes without creating a new PDF. | + --- From f0a325364c063b30cb57b60a6d643724b0fe816b Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:17:16 +0200 Subject: [PATCH 02/12] test(ll): add util testing functions --- src/gui/lltutorwindow.cpp | 103 ++++++++++++++++++++++++++++++++++++-- src/gui/lltutorwindow.h | 16 ++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/gui/lltutorwindow.cpp b/src/gui/lltutorwindow.cpp index 69f3fdc5..498355c6 100644 --- a/src/gui/lltutorwindow.cpp +++ b/src/gui/lltutorwindow.cpp @@ -210,6 +210,32 @@ bool LLTutorWindow::confirmExitToHome() { return msg.exec() == QMessageBox::Yes; } +QString LLTutorWindow::promptExportFilePath() const { +#ifdef SYNTAXTUTOR_TESTING + if (!nextExportFilePathForTest.isEmpty()) { + const QString filePath = nextExportFilePathForTest; + nextExportFilePathForTest.clear(); + return filePath; + } +#endif + + QFileDialog dialog(const_cast(this), + tr("Guardar conversación"), "conver.pdf", + tr("Archivo PDF (*.pdf)")); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setFileMode(QFileDialog::AnyFile); + dialog.selectFile("conver.pdf"); + dialog.setObjectName("llTutorExportFileDialog"); +#ifdef SYNTAXTUTOR_TESTING + dialog.setOption(QFileDialog::DontUseNativeDialog, true); +#endif + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + return dialog.selectedFiles().value(0); +} + void LLTutorWindow::on_backButton_clicked() { if (confirmExitToHome()) { requestExit(false); @@ -910,10 +936,12 @@ void LLTutorWindow::on_confirmButton_clicked() { layout->setSpacing(10); auto* exportBtn = new QPushButton(tr("Exportar PDF"), actions); + exportBtn->setObjectName("llTutorExportPdfButton"); exportBtn->setCursor(Qt::PointingHandCursor); exportBtn->setProperty("role", "primary"); auto* exitBtn = new QPushButton(tr("Salir"), actions); + exitBtn->setObjectName("llTutorExitButton"); exitBtn->setCursor(Qt::PointingHandCursor); exitBtn->setProperty("role", "danger"); @@ -921,9 +949,7 @@ void LLTutorWindow::on_confirmButton_clicked() { layout->addWidget(exitBtn); connect(exportBtn, &QPushButton::clicked, this, [this]() { - const QString filePath = QFileDialog::getSaveFileName( - this, tr("Guardar conversación"), "conver.pdf", - tr("Archivo PDF (*.pdf)")); + const QString filePath = promptExportFilePath(); if (!filePath.isEmpty()) { exportConversationToPdf(filePath); } @@ -1159,6 +1185,77 @@ void LLTutorWindow::updatePlaceholder() { ui->userResponse->setPlaceholderText(text); } +#ifdef SYNTAXTUTOR_TESTING +QString LLTutorWindow::currentStateForTest() const { + switch (currentState) { + case State::A: + return "A"; + case State::A1: + return "A1"; + case State::A2: + return "A2"; + case State::A_prime: + return "A'"; + case State::B: + return "B"; + case State::B1: + return "B1"; + case State::B2: + return "B2"; + case State::B_prime: + return "B'"; + case State::C: + return "C"; + case State::C_prime: + return "C'"; + case State::fin: + return "fin"; + } + + return {}; +} + +QString LLTutorWindow::currentRuleAntecedentForTest() const { + if (static_cast(currentRule) >= sortedGrammar.size()) { + return {}; + } + + return sortedGrammar.at(currentRule).first; +} + +QStringList LLTutorWindow::currentRuleConsequentForTest() const { + if (static_cast(currentRule) >= sortedGrammar.size()) { + return {}; + } + + QStringList consequent; + for (const QString& symbol : sortedGrammar.at(currentRule).second) { + consequent.append(symbol); + } + return consequent; +} + +int LLTutorWindow::rightCountForTest() const { + return static_cast(cntRightAnswers); +} + +int LLTutorWindow::wrongCountForTest() const { + return static_cast(cntWrongAnswers); +} + +void LLTutorWindow::setAnswerForTest(const QString& text) { + ui->userResponse->setPlainText(text); +} + +void LLTutorWindow::submitForTest() { + on_confirmButton_clicked(); +} + +void LLTutorWindow::setNextExportFilePathForTest(const QString& filePath) { + nextExportFilePathForTest = filePath; +} +#endif + /************************************************************ * VERIFY USER RESPONSE * * Dispatches validation to the appropriate method based on diff --git a/src/gui/lltutorwindow.h b/src/gui/lltutorwindow.h index 3f5fdf90..dc15ed19 100644 --- a/src/gui/lltutorwindow.h +++ b/src/gui/lltutorwindow.h @@ -205,6 +205,18 @@ class LLTutorWindow : public QWidget { const QStringList& colHeaders); void updatePlaceholder(); bool confirmExitToHome(); + QString promptExportFilePath() const; +#ifdef SYNTAXTUTOR_TESTING + public: + QString currentStateForTest() const; + QString currentRuleAntecedentForTest() const; + QStringList currentRuleConsequentForTest() const; + int rightCountForTest() const; + int wrongCountForTest() const; + void setAnswerForTest(const QString& text); + void submitForTest(); + void setNextExportFilePathForTest(const QString& filePath); +#endif private slots: void on_backButton_clicked(); void on_confirmButton_clicked(); @@ -289,6 +301,10 @@ class LLTutorWindow : public QWidget { TutorialManager* tm = nullptr; +#ifdef SYNTAXTUTOR_TESTING + mutable QString nextExportFilePathForTest; +#endif + const QRegularExpression kRe{"^\\s+|\\s+$"}; const QRegularExpression kWhitespace{"\\s+"}; }; From d1113bbcb0d7eeb0817ea0c8686da922038fd698 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:17:47 +0200 Subject: [PATCH 03/12] test(ll): add object names --- src/gui/lltabledialog.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/gui/lltabledialog.cpp b/src/gui/lltabledialog.cpp index 5f7b128c..aa7ddcd7 100644 --- a/src/gui/lltabledialog.cpp +++ b/src/gui/lltabledialog.cpp @@ -37,7 +37,9 @@ LLTableDialog::LLTableDialog(const QStringList& rowHeaders, QVector>* initialData) : QDialog(parent) { setProperty("tableDialog", true); + setObjectName("llTableDialog"); table = new QTableWidget(rowHeaders.size(), colHeaders.size(), this); + table->setObjectName("llTableWidget"); table->setItemDelegate(new CenterAlignDelegate(table)); table->setAlternatingRowColors(true); table->setHorizontalHeaderLabels(colHeaders); @@ -60,6 +62,7 @@ LLTableDialog::LLTableDialog(const QStringList& rowHeaders, table->horizontalHeader()->setStretchLastSection(true); submitButton = new QPushButton(tr("Finalizar"), this); + submitButton->setObjectName("llTableSubmitButton"); QFont submitButtonFont = submitButton->font(); submitButtonFont.setBold(true); submitButton->setFont(submitButtonFont); From f2db4d8ab91fca734fbad9fdd9944d0346716987 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:18:21 +0200 Subject: [PATCH 04/12] test(slr): add util test functions --- src/gui/slrtabledialog.cpp | 4 ++ src/gui/slrtutorwindow.cpp | 119 ++++++++++++++++++++++++++++++++++++- src/gui/slrtutorwindow.h | 16 +++++ src/gui/slrwizardpage.h | 4 ++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/gui/slrtabledialog.cpp b/src/gui/slrtabledialog.cpp index 09e57dab..4859d627 100644 --- a/src/gui/slrtabledialog.cpp +++ b/src/gui/slrtabledialog.cpp @@ -38,7 +38,9 @@ SLRTableDialog::SLRTableDialog(int rowCount, int colCount, QVector>* initialData) : QDialog(parent) { setProperty("tableDialog", true); + setObjectName("slrTableDialog"); table = new QTableWidget(rowCount, colCount, this); + table->setObjectName("slrTableWidget"); table->horizontalHeader()->setFont(table->font()); table->verticalHeader()->setFont(table->font()); table->setHorizontalHeaderLabels(colHeaders); @@ -65,11 +67,13 @@ SLRTableDialog::SLRTableDialog(int rowCount, int colCount, table->horizontalHeader()->setStretchLastSection(true); submitButton = new QPushButton(tr("Finalizar"), this); + submitButton->setObjectName("slrTableSubmitButton"); submitButton->setFont(submitButton->font()); submitButton->setCursor(Qt::PointingHandCursor); submitButton->setProperty("role", "primary"); guidedButton = new QPushButton(tr("Modo guiado"), this); + guidedButton->setObjectName("slrTableGuidedButton"); guidedButton->setFont(guidedButton->font()); guidedButton->setCursor(Qt::PointingHandCursor); guidedButton->setProperty("role", "primary"); diff --git a/src/gui/slrtutorwindow.cpp b/src/gui/slrtutorwindow.cpp index 5e5a949c..7a8e11c6 100644 --- a/src/gui/slrtutorwindow.cpp +++ b/src/gui/slrtutorwindow.cpp @@ -293,6 +293,32 @@ bool SLRTutorWindow::confirmExitToHome() { return msg.exec() == QMessageBox::Yes; } +QString SLRTutorWindow::promptExportFilePath() const { +#ifdef SYNTAXTUTOR_TESTING + if (!nextExportFilePathForTest.isEmpty()) { + const QString filePath = nextExportFilePathForTest; + nextExportFilePathForTest.clear(); + return filePath; + } +#endif + + QFileDialog dialog(const_cast(this), + tr("Guardar conversación"), "conver.pdf", + tr("Archivo PDF (*.pdf)")); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setFileMode(QFileDialog::AnyFile); + dialog.selectFile("conver.pdf"); + dialog.setObjectName("slrTutorExportFileDialog"); +#ifdef SYNTAXTUTOR_TESTING + dialog.setOption(QFileDialog::DontUseNativeDialog, true); +#endif + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + return dialog.selectedFiles().value(0); +} + void SLRTutorWindow::on_backButton_clicked() { if (confirmExitToHome()) { requestExit(false); @@ -1346,10 +1372,12 @@ void SLRTutorWindow::on_confirmButton_clicked() { layout->setSpacing(10); auto* exportBtn = new QPushButton(tr("Exportar PDF"), actions); + exportBtn->setObjectName("slrTutorExportPdfButton"); exportBtn->setCursor(Qt::PointingHandCursor); exportBtn->setProperty("role", "primary"); auto* exitBtn = new QPushButton(tr("Salir"), actions); + exitBtn->setObjectName("slrTutorExitButton"); exitBtn->setCursor(Qt::PointingHandCursor); exitBtn->setProperty("role", "danger"); @@ -1357,9 +1385,7 @@ void SLRTutorWindow::on_confirmButton_clicked() { layout->addWidget(exitBtn); connect(exportBtn, &QPushButton::clicked, this, [this]() { - const QString filePath = QFileDialog::getSaveFileName( - this, tr("Guardar conversación"), "conver.pdf", - tr("Archivo PDF (*.pdf)")); + const QString filePath = promptExportFilePath(); if (!filePath.isEmpty()) { exportConversationToPdf(filePath); } @@ -1795,6 +1821,93 @@ void SLRTutorWindow::updatePlaceholder() { ui->userResponse->setPlaceholderText(text); } +#ifdef SYNTAXTUTOR_TESTING +QString SLRTutorWindow::currentStateForTest() const { + switch (currentState) { + case StateSlr::A: + return "A"; + case StateSlr::A1: + return "A1"; + case StateSlr::A2: + return "A2"; + case StateSlr::A3: + return "A3"; + case StateSlr::A4: + return "A4"; + case StateSlr::A_prime: + return "A'"; + case StateSlr::B: + return "B"; + case StateSlr::C: + return "C"; + case StateSlr::CA: + return "CA"; + case StateSlr::CB: + return "CB"; + case StateSlr::D: + return "D"; + case StateSlr::D1: + return "D1"; + case StateSlr::D2: + return "D2"; + case StateSlr::D_prime: + return "D'"; + case StateSlr::E: + return "E"; + case StateSlr::E1: + return "E1"; + case StateSlr::E2: + return "E2"; + case StateSlr::F: + return "F"; + case StateSlr::FA: + return "FA"; + case StateSlr::G: + return "G"; + case StateSlr::H: + return "H"; + case StateSlr::H_prime: + return "H'"; + case StateSlr::fin: + return "fin"; + } + + return {}; +} + +int SLRTutorWindow::rightCountForTest() const { + return static_cast(cntRightAnswers); +} + +int SLRTutorWindow::wrongCountForTest() const { + return static_cast(cntWrongAnswers); +} + +void SLRTutorWindow::setAnswerForTest(const QString& text) { + ui->userResponse->setPlainText(text); +} + +void SLRTutorWindow::submitForTest() { + on_confirmButton_clicked(); +} + +unsigned SLRTutorWindow::currentStateIdForTest() const { + return currentStateId; +} + +QString SLRTutorWindow::currentCbSymbolForTest() const { + if (currentState != StateSlr::CB || currentFollowSymbolsIdx >= followSymbols.size()) { + return {}; + } + + return followSymbols.at(currentFollowSymbolsIdx); +} + +void SLRTutorWindow::setNextExportFilePathForTest(const QString& filePath) { + nextExportFilePathForTest = filePath; +} +#endif + /************************************************************ * VERIFY USER RESPONSES * * Dispatches the current user input to the appropriate diff --git a/src/gui/slrtutorwindow.h b/src/gui/slrtutorwindow.h index 4a18a2c4..237b34e3 100644 --- a/src/gui/slrtutorwindow.h +++ b/src/gui/slrtutorwindow.h @@ -222,6 +222,18 @@ class SLRTutorWindow : public QWidget { QString TeachClosure(const std::unordered_set& initialItems); void updatePlaceholder(); bool confirmExitToHome(); + QString promptExportFilePath() const; +#ifdef SYNTAXTUTOR_TESTING + public: + QString currentStateForTest() const; + int rightCountForTest() const; + int wrongCountForTest() const; + void setAnswerForTest(const QString& text); + void submitForTest(); + unsigned currentStateIdForTest() const; + QString currentCbSymbolForTest() const; + void setNextExportFilePathForTest(const QString& filePath); +#endif private slots: void on_backButton_clicked(); void on_confirmButton_clicked(); @@ -326,6 +338,10 @@ class SLRTutorWindow : public QWidget { TutorialManager* tm; +#ifdef SYNTAXTUTOR_TESTING + mutable QString nextExportFilePathForTest; +#endif + QRegularExpression re{"^\\s+|\\s+$"}; const QRegularExpression kWhitespace{"\\s+"}; }; diff --git a/src/gui/slrwizardpage.h b/src/gui/slrwizardpage.h index 927039e9..82f614c2 100644 --- a/src/gui/slrwizardpage.h +++ b/src/gui/slrwizardpage.h @@ -61,6 +61,7 @@ class SLRWizardPage : public QWizardPage { lbl->setWordWrap(true); m_edit = new QLineEdit(this); + m_edit->setObjectName("slrWizardAnswerEdit"); m_edit->setPlaceholderText( tr("Escribe tu respuesta (p.ej. s3, r2, acc, 5)")); @@ -72,6 +73,9 @@ class SLRWizardPage : public QWizardPage { connect(m_edit, &QLineEdit::textChanged, this, &SLRWizardPage::onTextChanged); } +#ifdef SYNTAXTUTOR_TESTING + QString expectedForTest() const { return m_expected; } +#endif private slots: /** * @brief Checks the user's input and enables the "Next" button only if From 6f4ca29476991fe72caf975b0a8751f80a4dad4e Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:18:38 +0200 Subject: [PATCH 05/12] test(tutorial): set object name --- src/widgets/tutorialmanager.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widgets/tutorialmanager.cpp b/src/widgets/tutorialmanager.cpp index 6cd50f90..4e399e90 100644 --- a/src/widgets/tutorialmanager.cpp +++ b/src/widgets/tutorialmanager.cpp @@ -155,6 +155,7 @@ void TutorialManager::showOverlay() { m_textBox->show(); m_nextBtn = new QPushButton("&Siguiente", m_overlay); + m_nextBtn->setObjectName("tutorialNextButton"); m_nextBtn->setCursor(Qt::PointingHandCursor); m_nextBtn->setStyleSheet(R"( QPushButton { From 244def596a572ed07336c45817d6f005e0129669 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:18:57 +0200 Subject: [PATCH 06/12] test(main): set object names for testing --- src/gui/mainwindow.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/gui/mainwindow.cpp b/src/gui/mainwindow.cpp index bfa229f8..8e77169a 100644 --- a/src/gui/mainwindow.cpp +++ b/src/gui/mainwindow.cpp @@ -31,6 +31,14 @@ namespace { +#ifdef SYNTAXTUTOR_TESTING +constexpr auto kSettingsOrg = "UMA-Test"; +constexpr auto kSettingsApp = "SyntaxTutor-Test"; +#else +constexpr auto kSettingsOrg = "UMA"; +constexpr auto kSettingsApp = "SyntaxTutor"; +#endif + void showInfoDialog(QWidget* parent, const QString& windowTitle, const QString& eyebrow, const QString& title, const QString& html) { @@ -81,7 +89,7 @@ void showInfoDialog(QWidget* parent, const QString& windowTitle, MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent), ui(new Ui::MainWindow), - settings("UMA", "SyntaxTutor") { + settings(kSettingsOrg, kSettingsApp) { factory.Init(); ui->setupUi(this); ui->homeEyebrow->setText(tr("Tutores interactivos")); @@ -688,14 +696,17 @@ void MainWindow::on_idiom_clicked() { buttonsLayout->setSpacing(10); auto* btnEs = new QPushButton(tr("Español"), &dialog); + btnEs->setObjectName("languageSpanishButton"); btnEs->setCursor(Qt::PointingHandCursor); btnEs->setProperty("role", "primary"); auto* btnEn = new QPushButton(tr("Inglés"), &dialog); + btnEn->setObjectName("languageEnglishButton"); btnEn->setCursor(Qt::PointingHandCursor); btnEn->setProperty("role", "primary"); auto* btnCanc = new QPushButton(tr("Cancelar"), &dialog); + btnCanc->setObjectName("languageCancelButton"); btnCanc->setCursor(Qt::PointingHandCursor); btnCanc->setProperty("role", "danger"); @@ -724,8 +735,7 @@ void MainWindow::on_idiom_clicked() { return; } - QSettings settings("UMA", "SyntaxTutor"); - QString currentLang = settings.value("lang/language", "es").toString(); + QString currentLang = settings.value("lang/language", "es").toString(); if (selectedLang != currentLang) { settings.setValue("lang/language", selectedLang); @@ -737,7 +747,9 @@ void MainWindow::on_idiom_clicked() { info.setStandardButtons(QMessageBox::Ok); info.exec(); +#ifndef SYNTAXTUTOR_TESTING qApp->quit(); QProcess::startDetached(qApp->applicationFilePath(), QStringList()); +#endif } } From fbaae2a3f8cfe88c1e3d1a8be1d45bbce957b0fe Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:21:29 +0200 Subject: [PATCH 07/12] test: add fixed grammars --- .../grammars/tutor_grammar_fixtures.cpp | 23 +++++++++++++++++++ .../grammars/tutor_grammar_fixtures.h | 12 ++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/fixtures/grammars/tutor_grammar_fixtures.cpp create mode 100644 tests/fixtures/grammars/tutor_grammar_fixtures.h diff --git a/tests/fixtures/grammars/tutor_grammar_fixtures.cpp b/tests/fixtures/grammars/tutor_grammar_fixtures.cpp new file mode 100644 index 00000000..3940ccd7 --- /dev/null +++ b/tests/fixtures/grammars/tutor_grammar_fixtures.cpp @@ -0,0 +1,23 @@ +#include "tutor_grammar_fixtures.h" + +#include + +namespace TutorGrammarFixtures { + +Grammar makeLl1SimpleGrammar() { + return Grammar({{"A", {{"a", "B"}, {"b"}}}, {"B", {{"c"}}}}); +} + +Grammar makeLl1EpsilonGrammar() { + return Grammar({{"A", {{"B"}}}, {"B", {{"b"}, {"EPSILON"}}}}); +} + +Grammar makeSlrSimpleGrammar() { + return Grammar({{"A", {{"a", "A"}, {"b"}}}}); +} + +Grammar makeSlrConflictGrammar() { + return Grammar({{"A", {{"a"}, {"a", "A"}}}}); +} + +} // namespace TutorGrammarFixtures diff --git a/tests/fixtures/grammars/tutor_grammar_fixtures.h b/tests/fixtures/grammars/tutor_grammar_fixtures.h new file mode 100644 index 00000000..5067738f --- /dev/null +++ b/tests/fixtures/grammars/tutor_grammar_fixtures.h @@ -0,0 +1,12 @@ +#pragma once + +#include "grammar.hpp" + +namespace TutorGrammarFixtures { + +Grammar makeLl1SimpleGrammar(); +Grammar makeLl1EpsilonGrammar(); +Grammar makeSlrSimpleGrammar(); +Grammar makeSlrConflictGrammar(); + +} // namespace TutorGrammarFixtures From d91985ca50967c1d7ef33be37805ec8bda7e8c45 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:24:04 +0200 Subject: [PATCH 08/12] test: add test helpers --- tests/helpers/ll1_tutor_test_utils.h | 80 +++++++ tests/helpers/qt_modal_test_utils.h | 255 ++++++++++++++++++++ tests/helpers/slr_tutor_test_utils.h | 333 +++++++++++++++++++++++++++ tests/helpers/tutor_scenario.h | 24 ++ 4 files changed, 692 insertions(+) create mode 100644 tests/helpers/ll1_tutor_test_utils.h create mode 100644 tests/helpers/qt_modal_test_utils.h create mode 100644 tests/helpers/slr_tutor_test_utils.h create mode 100644 tests/helpers/tutor_scenario.h diff --git a/tests/helpers/ll1_tutor_test_utils.h b/tests/helpers/ll1_tutor_test_utils.h new file mode 100644 index 00000000..0e06797f --- /dev/null +++ b/tests/helpers/ll1_tutor_test_utils.h @@ -0,0 +1,80 @@ +#pragma once + +#include "grammar.hpp" +#include "ll1_parser.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace Ll1TutorTestUtils { + +inline std::vector toStdVector(const QStringList& symbols) { + std::vector out; + out.reserve(static_cast(symbols.size())); + for (const QString& symbol : symbols) { + out.push_back(symbol.toStdString()); + } + return out; +} + +inline QString joinSet(const std::unordered_set& symbols) { + QStringList values; + for (const std::string& symbol : symbols) { + values.append(QString::fromStdString(symbol)); + } + std::sort(values.begin(), values.end()); + return values.join(','); +} + +inline QString tableSizeAnswer(const Grammar& grammar) { + return QString("%1,%2") + .arg(grammar.st_.non_terminals_.size()) + .arg(grammar.st_.terminals_.size()); +} + +inline QString nonTerminalCountAnswer(const Grammar& grammar) { + return QString::number(grammar.st_.non_terminals_.size()); +} + +inline QString terminalCountAnswer(const Grammar& grammar) { + return QString::number(grammar.st_.terminals_wtho_eol_.size()); +} + +inline QString predictionSymbolsAnswer(const Grammar& grammar, + const QString& antecedent, + const QStringList& consequent) { + LL1Parser parser(grammar); + return joinSet(parser.PredictionSymbols(antecedent.toStdString(), + toStdVector(consequent))); +} + +inline QString cabAnswer(const Grammar& grammar, + const QString& antecedent, + const QStringList& consequent) { + LL1Parser parser(grammar); + std::unordered_set result; + const std::vector rule = toStdVector(consequent); + + parser.First(rule, result); + if (antecedent.toStdString() == parser.gr_.axiom_ && !rule.empty() && + rule.back() == parser.gr_.st_.EOL_ && + result.contains(parser.gr_.st_.EPSILON_)) { + result.erase(parser.gr_.st_.EPSILON_); + result.insert(parser.gr_.st_.EOL_); + } + + return joinSet(result); +} + +inline QString followAnswer(const Grammar& grammar, const QString& antecedent) { + LL1Parser parser(grammar); + return joinSet(parser.Follow(antecedent.toStdString())); +} + +} // namespace Ll1TutorTestUtils diff --git a/tests/helpers/qt_modal_test_utils.h b/tests/helpers/qt_modal_test_utils.h new file mode 100644 index 00000000..6af54cc2 --- /dev/null +++ b/tests/helpers/qt_modal_test_utils.h @@ -0,0 +1,255 @@ +#pragma once + +#include "grammar.hpp" +#include "ll1_parser.hpp" +#include "lltabledialog.h" +#include "slrtabledialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace QtModalTestUtils { + +inline void scheduleUntilHandled(const std::function& handler) { + auto retry = std::make_shared>(); + *retry = [handler, retry]() { + if (handler()) { + return; + } + QTimer::singleShot(10, [retry]() { (*retry)(); }); + }; + + QTimer::singleShot(0, [retry]() { (*retry)(); }); +} + +template T* findVisibleTopLevelWidget() { + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (auto* candidate = qobject_cast(widget); + candidate != nullptr && candidate->isVisible()) { + return candidate; + } + } + return nullptr; +} + +template T* waitForVisibleTopLevelWidget(int timeoutMs = 2000) { + const int intervalMs = 20; + int elapsed = 0; + + while (elapsed <= timeoutMs) { + if (T* candidate = findVisibleTopLevelWidget()) { + return candidate; + } + QTest::qWait(intervalMs); + elapsed += intervalMs; + } + + return nullptr; +} + +inline QPushButton* findButtonByText(QWidget* parent, const QString& text) { + if (parent == nullptr) { + return nullptr; + } + + for (QPushButton* button : parent->findChildren()) { + if (button->text() == text) { + return button; + } + } + + return nullptr; +} + +inline void scheduleMessageBoxResponse(QMessageBox::StandardButton button) { + scheduleUntilHandled([button]() { + auto* messageBox = findVisibleTopLevelWidget(); + if (messageBox == nullptr) { + return false; + } + + QPushButton* target = + qobject_cast(messageBox->button(button)); + if (target == nullptr) { + target = findButtonByText(messageBox, button == QMessageBox::Yes + ? QStringLiteral("Si") + : QStringLiteral("No")); + } + if (target == nullptr) { + target = findButtonByText(messageBox, QStringLiteral("OK")); + } + if (target == nullptr) { + target = findButtonByText(messageBox, QStringLiteral("Aceptar")); + } + if (target == nullptr) { + return false; + } + + QTest::mouseClick(target, Qt::LeftButton); + return true; + }); +} + +inline void scheduleFileDialogSelection(const QString& filePath) { + scheduleUntilHandled([filePath]() { + auto* dialog = findVisibleTopLevelWidget(); + if (dialog == nullptr) { + return false; + } + + dialog->selectFile(filePath); + QMetaObject::invokeMethod(dialog, "accept", Qt::DirectConnection); + return true; + }); +} + +inline QStringList horizontalHeaders(QTableWidget* table) { + QStringList headers; + for (int col = 0; col < table->columnCount(); ++col) { + headers.append(table->horizontalHeaderItem(col)->text()); + } + return headers; +} + +inline QStringList verticalHeaders(QTableWidget* table) { + QStringList headers; + for (int row = 0; row < table->rowCount(); ++row) { + headers.append(table->verticalHeaderItem(row)->text()); + } + return headers; +} + +inline QVector> buildExpectedTable(const Grammar& grammar, + QTableWidget* table) { + LL1Parser parser(grammar); + parser.CreateLL1Table(); + + const QStringList rows = verticalHeaders(table); + const QStringList cols = horizontalHeaders(table); + + QVector> raw(rows.size(), QVector(cols.size())); + for (int row = 0; row < rows.size(); ++row) { + for (int col = 0; col < cols.size(); ++col) { + const auto rowIt = parser.ll1_t_.find(rows.at(row).toStdString()); + if (rowIt == parser.ll1_t_.end()) { + continue; + } + + const auto colIt = rowIt->second.find(cols.at(col).toStdString()); + if (colIt == rowIt->second.end() || colIt->second.empty()) { + continue; + } + + QStringList production; + for (const std::string& symbol : colIt->second.front()) { + production.append(QString::fromStdString(symbol)); + } + raw[row][col] = production.join(' '); + } + } + + return raw; +} + +inline QVector> buildWrongTable(const Grammar& grammar, + QTableWidget* table) { + QVector> raw = buildExpectedTable(grammar, table); + + for (int row = 0; row < raw.size(); ++row) { + for (int col = 0; col < raw[row].size(); ++col) { + if (!raw[row][col].isEmpty()) { + raw[row][col] = QStringLiteral("WRONG"); + return raw; + } + } + } + + if (!raw.isEmpty() && !raw.first().isEmpty()) { + raw[0][0] = QStringLiteral("WRONG"); + } + return raw; +} + +inline void setTableData(QTableWidget* table, const QVector>& raw) { + for (int row = 0; row < table->rowCount(); ++row) { + for (int col = 0; col < table->columnCount(); ++col) { + QTableWidgetItem* item = table->item(row, col); + if (item == nullptr) { + item = new QTableWidgetItem(); + table->setItem(row, col, item); + } + item->setText(raw[row][col]); + } + } +} + +inline QColor cellBackground(QTableWidget* table, int row, int col) { + QTableWidgetItem* item = table->item(row, col); + return item == nullptr ? QColor() : item->background().color(); +} + +inline int firstNonEmptyCellRow(const QVector>& raw) { + for (int row = 0; row < raw.size(); ++row) { + for (int col = 0; col < raw[row].size(); ++col) { + if (!raw[row][col].isEmpty()) { + return row; + } + } + } + return -1; +} + +inline int firstNonEmptyCellCol(const QVector>& raw) { + for (int row = 0; row < raw.size(); ++row) { + for (int col = 0; col < raw[row].size(); ++col) { + if (!raw[row][col].isEmpty()) { + return col; + } + } + } + return -1; +} + +inline void submitLlTableDialog(LLTableDialog* dialog, + const QVector>& raw) { + auto* table = dialog->findChild("llTableWidget"); + auto* button = dialog->findChild("llTableSubmitButton"); + QVERIFY(table != nullptr); + QVERIFY(button != nullptr); + + setTableData(table, raw); + QTest::mouseClick(button, Qt::LeftButton); +} + +inline void submitSlrTableDialog(SLRTableDialog* dialog, + const QVector>& raw) { + auto* table = dialog->findChild("slrTableWidget"); + auto* button = dialog->findChild("slrTableSubmitButton"); + QVERIFY(table != nullptr); + QVERIFY(button != nullptr); + + setTableData(table, raw); + QTest::mouseClick(button, Qt::LeftButton); +} + +inline void requestSlrGuidedMode(SLRTableDialog* dialog, + const QVector>& raw) { + auto* table = dialog->findChild("slrTableWidget"); + auto* button = dialog->findChild("slrTableGuidedButton"); + QVERIFY(table != nullptr); + QVERIFY(button != nullptr); + + setTableData(table, raw); + QTest::mouseClick(button, Qt::LeftButton); +} + +} // namespace QtModalTestUtils diff --git a/tests/helpers/slr_tutor_test_utils.h b/tests/helpers/slr_tutor_test_utils.h new file mode 100644 index 00000000..f7ce71ba --- /dev/null +++ b/tests/helpers/slr_tutor_test_utils.h @@ -0,0 +1,333 @@ +#pragma once + +#include "lr0_item.hpp" +#include "qt_modal_test_utils.h" +#include "slr1_parser.hpp" +#include "slrtutorwindow.h" +#include "slrwizardpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace SlrTutorTestUtils { + +inline QString productionToCompactString(const std::vector& production, + unsigned dot) { + QString result; + for (unsigned i = 0; i < production.size(); ++i) { + if (i == dot) { + result += '.'; + } + result += QString::fromStdString(production[i]); + } + if (dot >= production.size()) { + result += '.'; + } + return result; +} + +inline QString itemToAnswer(const Lr0Item& item) { + return QString("%1 -> %2") + .arg(QString::fromStdString(item.antecedent_)) + .arg(productionToCompactString(item.consequent_, item.dot_)); +} + +inline QStringList sortedItemAnswers(const std::unordered_set& items) { + QStringList lines; + for (const Lr0Item& item : items) { + lines.append(itemToAnswer(item)); + } + std::sort(lines.begin(), lines.end()); + return lines; +} + +inline QString itemsAnswer(const std::unordered_set& items) { + return sortedItemAnswers(items).join('\n'); +} + +inline QString rulesAnswer( + const std::vector>>& rules) { + QStringList lines; + for (const auto& [lhs, rhs] : rules) { + QString consequent; + for (const std::string& symbol : rhs) { + consequent += QString::fromStdString(symbol); + } + lines.append(QString("%1 -> %2") + .arg(QString::fromStdString(lhs)) + .arg(consequent)); + } + return lines.join('\n'); +} + +inline QString sortedCommaAnswer(QStringList values) { + values.removeAll({}); + std::sort(values.begin(), values.end()); + return values.join(','); +} + +inline QString stringSetAnswer(const QSet& values) { + return sortedCommaAnswer(QStringList(values.begin(), values.end())); +} + +inline QString idSetAnswer(const QSet& values) { + QStringList parts; + for (unsigned value : values) { + parts.append(QString::number(value)); + } + return sortedCommaAnswer(parts); +} + +inline QString idCountAnswer(const QMap& values) { + QStringList parts; + for (auto it = values.cbegin(); it != values.cend(); ++it) { + parts.append(QString("%1:%2").arg(it.key()).arg(it.value())); + } + return parts.join(','); +} + +inline QString sortedSymbolsAnswer(const QStringList& values) { + return sortedCommaAnswer(values); +} + +inline QString correctAnswerForCurrentState(SLRTutorWindow& tutor) { + const QString state = tutor.currentStateForTest(); + + if (state == "A" || state == "A'") { + return itemsAnswer(tutor.solutionForA()); + } + if (state == "A1") { + return tutor.solutionForA1(); + } + if (state == "A2") { + return tutor.solutionForA2(); + } + if (state == "A3") { + return rulesAnswer(tutor.solutionForA3()); + } + if (state == "A4") { + return itemsAnswer(tutor.solutionForA4()); + } + if (state == "B") { + return QString::number(tutor.solutionForB()); + } + if (state == "C") { + return QString::number(tutor.solutionForC()); + } + if (state == "CA") { + return sortedSymbolsAnswer(tutor.solutionForCA()); + } + if (state == "CB") { + return tutor.currentCbSymbolForTest() == "EPSILON" + ? QString() + : itemsAnswer(tutor.solutionForCB()); + } + if (state == "D" || state == "D'") { + return tutor.solutionForD().join(','); + } + if (state == "D1") { + return tutor.solutionForD1(); + } + if (state == "D2") { + return tutor.solutionForD2(); + } + if (state == "E") { + return QString::number(tutor.solutionForE()); + } + if (state == "E1") { + return idSetAnswer(tutor.solutionForE1()); + } + if (state == "E2") { + return idCountAnswer(tutor.solutionForE2()); + } + if (state == "F") { + return idSetAnswer(tutor.solutionForF()); + } + if (state == "FA") { + return stringSetAnswer(tutor.solutionForFA()); + } + if (state == "G") { + return stringSetAnswer(tutor.solutionForG()); + } + + return {}; +} + +inline void submitCorrectAnswerForCurrentState(SLRTutorWindow& tutor) { + tutor.setAnswerForTest(correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); +} + +inline void driveTutorToState(SLRTutorWindow& tutor, const QString& targetState, + int maxSteps = 200) { + int steps = 0; + while (tutor.currentStateForTest() != targetState && steps < maxSteps) { + submitCorrectAnswerForCurrentState(tutor); + ++steps; + } + QCOMPARE(tutor.currentStateForTest(), targetState); +} + +inline QVector>> buildSortedGrammar( + const Grammar& grammar) { + QVector sortedNonTerminals; + for (const std::string& nonTerminal : grammar.st_.non_terminals_) { + sortedNonTerminals.append(QString::fromStdString(nonTerminal)); + } + std::ranges::sort(sortedNonTerminals, [](const QString& a, const QString& b) { + if (a == "S") + return true; + if (b == "S") + return false; + return a < b; + }); + + QVector>> rules; + for (const QString& nt : std::as_const(sortedNonTerminals)) { + for (const production& prod : grammar.g_.at(nt.toStdString())) { + QVector rhs; + for (const std::string& symbol : prod) { + rhs.append(QString::fromStdString(symbol)); + } + rules.append({nt, rhs}); + } + } + return rules; +} + +inline QVector> buildExpectedTable(const Grammar& grammar, + QTableWidget* table) { + SLR1Parser parser(grammar); + parser.MakeParser(); + const auto sortedGrammar = buildSortedGrammar(grammar); + const QStringList cols = QtModalTestUtils::horizontalHeaders(table); + QVector> raw(table->rowCount(), QVector(table->columnCount())); + + for (const state& slrState : parser.states_) { + const int row = static_cast(slrState.id_); + for (int col = 0; col < cols.size(); ++col) { + const QString symbol = cols.at(col); + bool filled = false; + + if (auto actMapIt = parser.actions_.find(slrState.id_); + actMapIt != parser.actions_.end()) { + auto actIt = actMapIt->second.find(symbol.toStdString()); + if (actIt != actMapIt->second.end()) { + switch (actIt->second.action) { + case SLR1Parser::Action::Shift: + raw[row][col] = QString("s%1") + .arg(parser.transitions_.at(slrState.id_) + .at(symbol.toStdString())); + filled = true; + break; + case SLR1Parser::Action::Reduce: { + int prodIdx = -1; + QVector consequent; + for (const std::string& token : actIt->second.item->consequent_) { + consequent.append(QString::fromStdString(token)); + } + for (int i = 0; i < sortedGrammar.size(); ++i) { + const auto& rule = sortedGrammar.at(i); + if (rule.first.toStdString() == actIt->second.item->antecedent_ && + rule.second == consequent) { + prodIdx = i; + break; + } + } + raw[row][col] = QString("r%1").arg(prodIdx); + filled = true; + break; + } + case SLR1Parser::Action::Accept: + raw[row][col] = "acc"; + filled = true; + break; + case SLR1Parser::Action::Empty: + break; + } + } + } + + if (!filled) { + if (auto transIt = parser.transitions_.find(slrState.id_); + transIt != parser.transitions_.end()) { + auto gotoIt = transIt->second.find(symbol.toStdString()); + if (gotoIt != transIt->second.end()) { + raw[row][col] = QString::number(gotoIt->second); + } + } + } + } + } + + return raw; +} + +inline QVector> buildWrongTable(const Grammar& grammar, + QTableWidget* table) { + QVector> raw = buildExpectedTable(grammar, table); + for (int row = 0; row < raw.size(); ++row) { + for (int col = 0; col < raw[row].size(); ++col) { + if (!raw[row][col].isEmpty()) { + const QString value = raw[row][col]; + if (value == "acc") { + raw[row][col] = QStringLiteral("s0"); + } else if (value.startsWith('s')) { + raw[row][col] = QStringLiteral("s999"); + } else if (value.startsWith('r')) { + raw[row][col] = QStringLiteral("r999"); + } else { + raw[row][col] = QStringLiteral("999"); + } + return raw; + } + } + } + if (!raw.isEmpty() && !raw.first().isEmpty()) { + raw[0][0] = QStringLiteral("999"); + } + return raw; +} + +inline void finishWizard(QWizard* wizard) { + QPointer wizardGuard(wizard); + QVERIFY(wizardGuard != nullptr); + while (wizardGuard != nullptr && wizardGuard->isVisible()) { + auto* page = qobject_cast(wizardGuard->currentPage()); + QVERIFY(page != nullptr); + auto* edit = page->findChild("slrWizardAnswerEdit"); + QVERIFY(edit != nullptr); + edit->setText(page->expectedForTest()); + QApplication::processEvents(); + + const bool isFinalPage = page->isFinalPage(); + QPushButton* nextButton = qobject_cast( + wizardGuard->button(isFinalPage + ? QWizard::FinishButton + : QWizard::NextButton)); + QVERIFY(nextButton != nullptr); + QTest::mouseClick(nextButton, Qt::LeftButton); + QApplication::processEvents(); + + if (isFinalPage) { + break; + } + if (wizardGuard == nullptr || !wizardGuard->isVisible()) { + break; + } + } +} + +} // namespace SlrTutorTestUtils diff --git a/tests/helpers/tutor_scenario.h b/tests/helpers/tutor_scenario.h new file mode 100644 index 00000000..20e36890 --- /dev/null +++ b/tests/helpers/tutor_scenario.h @@ -0,0 +1,24 @@ +#pragma once + +#include "lltutorwindow.h" + +#include + +struct TutorStep { + QString input; + QString expectedState; + int expectedRight; + int expectedWrong; +}; + +inline void runScenario(LLTutorWindow& tutor, const QList& steps) { + for (qsizetype i = 0; i < steps.size(); ++i) { + const TutorStep& step = steps.at(i); + tutor.setAnswerForTest(step.input); + tutor.submitForTest(); + + QCOMPARE(tutor.currentStateForTest(), step.expectedState); + QCOMPARE(tutor.rightCountForTest(), step.expectedRight); + QCOMPARE(tutor.wrongCountForTest(), step.expectedWrong); + } +} From cdd5d39c7e62fa00217ea78789c8989f4541eae4 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 16:24:53 +0200 Subject: [PATCH 09/12] test: add UI tests --- tests/tests.pro | 76 ++++ tests/tutor/ll_tutor_window_test.cpp | 418 ++++++++++++++++++++++ tests/tutor/main_window_test.cpp | 326 +++++++++++++++++ tests/tutor/slr_tutor_window_test.cpp | 483 ++++++++++++++++++++++++++ tests/tutor/tutor_test_main.cpp | 10 + tests/tutor/tutor_window_test.h | 52 +++ 6 files changed, 1365 insertions(+) create mode 100644 tests/tests.pro create mode 100644 tests/tutor/ll_tutor_window_test.cpp create mode 100644 tests/tutor/main_window_test.cpp create mode 100644 tests/tutor/slr_tutor_window_test.cpp create mode 100644 tests/tutor/tutor_test_main.cpp create mode 100644 tests/tutor/tutor_window_test.h diff --git a/tests/tests.pro b/tests/tests.pro new file mode 100644 index 00000000..05b12f7e --- /dev/null +++ b/tests/tests.pro @@ -0,0 +1,76 @@ +QT += core gui widgets testlib printsupport svg + +CONFIG += c++20 testcase console +CONFIG -= app_bundle + +TARGET = tutor_tests +DEFINES += SYNTAXTUTOR_TESTING + +INCLUDEPATH += \ + $$PWD/.. \ + $$PWD/../src \ + $$PWD/../src/app \ + $$PWD/../src/gui \ + $$PWD/../src/widgets \ + $$PWD/../src/backend \ + $$PWD/helpers \ + $$PWD/fixtures/grammars + +OBJECTS_DIR = .objects +MOC_DIR = .moc +RCC_DIR = .rcc +UI_DIR = .ui + +SOURCES += \ + ../src/backend/grammar.cpp \ + ../src/backend/grammar_factory.cpp \ + ../src/backend/ll1_parser.cpp \ + ../src/backend/lr0_item.cpp \ + ../src/backend/slr1_parser.cpp \ + ../src/backend/symbol_table.cpp \ + ../src/widgets/customtextedit.cpp \ + ../src/widgets/grammarview.cpp \ + ../src/widgets/tutorialmanager.cpp \ + ../src/gui/lltabledialog.cpp \ + ../src/gui/lltutorwindow.cpp \ + ../src/gui/mainwindow.cpp \ + ../src/gui/slrtabledialog.cpp \ + ../src/gui/slrtutorwindow.cpp \ + fixtures/grammars/tutor_grammar_fixtures.cpp \ + tutor/ll_tutor_window_test.cpp \ + tutor/main_window_test.cpp \ + tutor/slr_tutor_window_test.cpp \ + tutor/tutor_test_main.cpp + +HEADERS += \ + ../src/backend/grammar.hpp \ + ../src/backend/grammar_factory.hpp \ + ../src/backend/ll1_parser.hpp \ + ../src/backend/lr0_item.hpp \ + ../src/backend/slr1_parser.hpp \ + ../src/backend/state.hpp \ + ../src/backend/symbol_table.hpp \ + ../src/widgets/customtextedit.h \ + ../src/widgets/grammarview.h \ + ../src/widgets/tutorialmanager.h \ + ../src/gui/lltabledialog.h \ + ../src/gui/lltutorwindow.h \ + ../src/gui/mainwindow.h \ + ../src/gui/slrtabledialog.h \ + ../src/gui/slrtutorwindow.h \ + ../src/gui/slrwizard.h \ + ../src/gui/slrwizardpage.h \ + fixtures/grammars/tutor_grammar_fixtures.h \ + helpers/ll1_tutor_test_utils.h \ + helpers/qt_modal_test_utils.h \ + helpers/slr_tutor_test_utils.h \ + helpers/tutor_scenario.h \ + tutor/tutor_window_test.h + +FORMS += \ + ../src/gui/lltutorwindow.ui \ + ../src/gui/mainwindow.ui \ + ../src/gui/slrtutorwindow.ui + +RESOURCES += \ + ../resources.qrc diff --git a/tests/tutor/ll_tutor_window_test.cpp b/tests/tutor/ll_tutor_window_test.cpp new file mode 100644 index 00000000..7dd644e8 --- /dev/null +++ b/tests/tutor/ll_tutor_window_test.cpp @@ -0,0 +1,418 @@ +#include "tutor_window_test.h" + +#include "ll1_tutor_test_utils.h" +#include "lltutorwindow.h" +#include "qt_modal_test_utils.h" +#include "tutor_grammar_fixtures.h" +#include "tutor_scenario.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void answerRemainingRulesUntilStateC(LLTutorWindow& tutor, const Grammar& grammar) { + while (tutor.currentStateForTest() == "B") { + tutor.setAnswerForTest(Ll1TutorTestUtils::predictionSymbolsAnswer( + grammar, tutor.currentRuleAntecedentForTest(), + tutor.currentRuleConsequentForTest())); + tutor.submitForTest(); + } +} + +LLTableDialog* waitForTableDialog() { + return QtModalTestUtils::waitForVisibleTopLevelWidget(); +} + +void driveTutorToCPrime(LLTutorWindow& tutor, const Grammar& grammar) { + LLTableDialog* dialog = waitForTableDialog(); + auto* table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); + + const QVector> wrongTable = + QtModalTestUtils::buildWrongTable(grammar, table); + + for (int attempt = 0; attempt < 3; ++attempt) { + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C")); + QVERIFY(dialog->isVisible()); + } + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C")); + QVERIFY(dialog->isVisible()); + + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C'")); +} + +QPushButton* waitForTutorButton(LLTutorWindow& tutor, const QString& objectName) { + const int intervalMs = 20; + int elapsed = 0; + + while (elapsed <= 2000) { + if (auto* button = tutor.findChild(objectName)) { + return button; + } + QTest::qWait(intervalMs); + elapsed += intervalMs; + } + + return nullptr; +} + +} // namespace + +void TutorWindowTest::createsTutorWithNullTutorialManager() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + QCOMPARE(tutor.currentStateForTest(), QString("A")); + QCOMPARE(tutor.rightCountForTest(), 0); + QCOMPARE(tutor.wrongCountForTest(), 0); +} + +void TutorWindowTest::stateAErrorPathAdvancesThroughAStates() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, + {{"1,1", "A1", 0, 1}, + {"99", "A1", 0, 2}, + {Ll1TutorTestUtils::nonTerminalCountAnswer(grammar), "A2", 1, + 2}, + {"99", "A2", 1, 3}, + {Ll1TutorTestUtils::terminalCountAnswer(grammar), "A'", 2, 3}, + {"1,1", "B", 2, 4}}); +} + +void TutorWindowTest::stateACorrectPathAdvancesToB() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); +} + +void TutorWindowTest::stateBAxiomBranchSkipsB2() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + + const QString firstAntecedent = tutor.currentRuleAntecedentForTest(); + QCOMPARE(firstAntecedent, QString("S")); + + runScenario(tutor, + {{"x", "B1", 1, 1}, + {Ll1TutorTestUtils::cabAnswer(grammar, firstAntecedent, + tutor.currentRuleConsequentForTest()), + "B'", 2, 1}}); +} + +void TutorWindowTest::stateBDirectAndFallbackPathsUpdateCounters() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + + const QString firstRuleAntecedent = tutor.currentRuleAntecedentForTest(); + const QString firstRulePrediction = Ll1TutorTestUtils::predictionSymbolsAnswer( + grammar, firstRuleAntecedent, tutor.currentRuleConsequentForTest()); + + runScenario(tutor, {{firstRulePrediction, "B", 2, 0}}); + + const QString currentAntecedent = tutor.currentRuleAntecedentForTest(); + const QStringList currentConsequent = tutor.currentRuleConsequentForTest(); + + runScenario(tutor, + {{"x", "B1", 2, 1}, + {Ll1TutorTestUtils::cabAnswer(grammar, currentAntecedent, + currentConsequent), + "B2", 3, 1}, + {Ll1TutorTestUtils::followAnswer(grammar, currentAntecedent), + "B'", 4, 1}, + {"x", "B", 4, 2}}); +} + +void TutorWindowTest::stateBWrongAnswersStayInB1AndB2() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + + const QString firstAntecedent = tutor.currentRuleAntecedentForTest(); + const QString firstPrediction = Ll1TutorTestUtils::predictionSymbolsAnswer( + grammar, firstAntecedent, tutor.currentRuleConsequentForTest()); + runScenario(tutor, {{firstPrediction, "B", 2, 0}}); + + const QString nonAxiomAntecedent = tutor.currentRuleAntecedentForTest(); + const QStringList nonAxiomConsequent = tutor.currentRuleConsequentForTest(); + + runScenario(tutor, + {{"x", "B1", 2, 1}, + {"x", "B1", 2, 2}, + {Ll1TutorTestUtils::cabAnswer(grammar, nonAxiomAntecedent, + nonAxiomConsequent), + "B2", 3, 2}, + {"x", "B2", 3, 3}, + {Ll1TutorTestUtils::followAnswer(grammar, nonAxiomAntecedent), + "B'", 4, 3}}); +} + +void TutorWindowTest::exportsConversationToPdf() { + const Grammar grammar = TutorGrammarFixtures::makeLl1EpsilonGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + tutor.setAnswerForTest(Ll1TutorTestUtils::tableSizeAnswer(grammar)); + tutor.submitForTest(); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString pdfPath = tempDir.filePath("ll_tutor_test.pdf"); + tutor.exportConversationToPdf(pdfPath); + + QFileInfo pdfInfo(pdfPath); + QVERIFY(pdfInfo.exists()); + QVERIFY(pdfInfo.size() > 0); +} + +void TutorWindowTest::stateCCorrectPathOpensExportActions() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + 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(waitForTutorButton(tutor, "llTutorExportPdfButton") != nullptr); + QVERIFY(waitForTutorButton(tutor, "llTutorExitButton") != nullptr); +} + +void TutorWindowTest::stateCWrongAttemptsReachCPrimeAndRecover() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + 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); + + const QVector> expectedTable = + QtModalTestUtils::buildExpectedTable(grammar, table); + const QVector> wrongTable = + QtModalTestUtils::buildWrongTable(grammar, table); + const int wrongRow = QtModalTestUtils::firstNonEmptyCellRow(expectedTable); + const int wrongCol = QtModalTestUtils::firstNonEmptyCellCol(expectedTable); + QVERIFY(wrongRow >= 0); + QVERIFY(wrongCol >= 0); + + for (int attempt = 0; attempt < 3; ++attempt) { + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C")); + QCOMPARE(tutor.wrongCountForTest(), 0); + QCOMPARE(QtModalTestUtils::cellBackground(table, wrongRow, wrongCol), + QColor("#d9534f")); + QVERIFY(dialog->isVisible()); + } + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C")); + QCOMPARE(tutor.wrongCountForTest(), 0); + QVERIFY(dialog->isVisible()); + + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C'")); + QCOMPARE(tutor.wrongCountForTest(), 1); + + dialog = waitForTableDialog(); + table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::submitLlTableDialog(dialog, wrongTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("C'")); + QCOMPARE(tutor.wrongCountForTest(), 2); + + dialog = waitForTableDialog(); + table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::submitLlTableDialog(dialog, expectedTable); + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); +} + +void TutorWindowTest::tableDialogCancelNoReopensAndYesRequestsExit() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + QSignalSpy exitSpy(&tutor, &LLTutorWindow::exitRequested); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); + + LLTableDialog* dialog = waitForTableDialog(); + QVERIFY(dialog != nullptr); + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::No); + dialog->reject(); + + LLTableDialog* reopenedDialog = waitForTableDialog(); + QVERIFY(reopenedDialog != nullptr); + QVERIFY(reopenedDialog != dialog); + QCOMPARE(exitSpy.count(), 0); + QCOMPARE(tutor.currentStateForTest(), QString("C")); + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Yes); + reopenedDialog->reject(); + + QTRY_COMPARE(exitSpy.count(), 1); + const QList arguments = exitSpy.takeFirst(); + QCOMPARE(arguments.at(0).toBool(), false); +} + +void TutorWindowTest::tableDialogCancelInCPrimeNoReopensAndYesRequestsExit() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + QSignalSpy exitSpy(&tutor, &LLTutorWindow::exitRequested); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); + + driveTutorToCPrime(tutor, grammar); + + LLTableDialog* dialog = waitForTableDialog(); + QVERIFY(dialog != nullptr); + + dialog = waitForTableDialog(); + QVERIFY(dialog != nullptr); + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::No); + dialog->reject(); + + LLTableDialog* reopenedDialog = waitForTableDialog(); + QVERIFY(reopenedDialog != nullptr); + QVERIFY(reopenedDialog != dialog); + QCOMPARE(exitSpy.count(), 0); + QCOMPARE(tutor.currentStateForTest(), QString("C'")); + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Yes); + reopenedDialog->reject(); + + QTRY_COMPARE(exitSpy.count(), 1); + const QList arguments = exitSpy.takeFirst(); + QCOMPARE(arguments.at(0).toBool(), false); +} + +void TutorWindowTest::exportButtonExportsPdf() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); + + LLTableDialog* dialog = waitForTableDialog(); + auto* table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::submitLlTableDialog( + dialog, QtModalTestUtils::buildExpectedTable(grammar, table)); + + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString pdfPath = tempDir.filePath("ll_export_via_dialog.pdf"); + + tutor.setNextExportFilePathForTest(pdfPath); + auto* exportButton = waitForTutorButton(tutor, "llTutorExportPdfButton"); + QTest::mouseClick(exportButton, Qt::LeftButton); + + QTRY_VERIFY(QFileInfo::exists(pdfPath)); + QFileInfo pdfInfo(pdfPath); + QVERIFY(pdfInfo.size() > 0); +} + +void TutorWindowTest::exitButtonFinishesWithoutExport() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + QSignalSpy exitSpy(&tutor, &LLTutorWindow::exitRequested); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); + + LLTableDialog* dialog = waitForTableDialog(); + auto* table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::submitLlTableDialog( + dialog, QtModalTestUtils::buildExpectedTable(grammar, table)); + + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + + auto* exitButton = waitForTutorButton(tutor, "llTutorExitButton"); + QVERIFY(exitButton != nullptr); + QTest::mouseClick(exitButton, Qt::LeftButton); + + QTRY_COMPARE(exitSpy.count(), 1); + const QList arguments = exitSpy.takeFirst(); + QCOMPARE(arguments.at(0).toBool(), true); +} + +void TutorWindowTest::smokeGuiFindsCoreWidgets() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + + LLTutorWindow tutor(grammar, nullptr); + tutor.show(); + QCoreApplication::processEvents(); + + QVERIFY(tutor.findChild("listWidget") != nullptr); + QVERIFY(tutor.findChild("userResponse") != nullptr); + QVERIFY(tutor.findChild("confirmButton") != nullptr); + QVERIFY(tutor.findChild("backButton") != nullptr); + + tutor.setAnswerForTest(Ll1TutorTestUtils::tableSizeAnswer(grammar)); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); +} diff --git a/tests/tutor/main_window_test.cpp b/tests/tutor/main_window_test.cpp new file mode 100644 index 00000000..8248f44c --- /dev/null +++ b/tests/tutor/main_window_test.cpp @@ -0,0 +1,326 @@ +#include "tutor_window_test.h" + +#include "mainwindow.h" +#include "lltutorwindow.h" +#include "qt_modal_test_utils.h" +#include "slrtutorwindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QSettings testAppSettings() { + return QSettings("UMA-Test", "SyntaxTutor-Test"); +} + +void clearTestAppSettings() { + QSettings settings = testAppSettings(); + settings.clear(); + settings.sync(); +} + +QDialog* findInfoDialog() { + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (auto* dialog = qobject_cast(widget); + dialog != nullptr && dialog->objectName() == "infoDialog" && + dialog->isVisible()) { + return dialog; + } + } + return nullptr; +} + +QPushButton* findVisibleTutorialNextButton() { + for (QWidget* widget : QApplication::topLevelWidgets()) { + for (QPushButton* button : widget->findChildren()) { + if (button->objectName() == "tutorialNextButton" && + button->isVisible()) { + return button; + } + } + } + return nullptr; +} + +void clickInfoDialogClose(QDialog* dialog) { + QVERIFY(dialog != nullptr); + QPushButton* closeButton = QtModalTestUtils::findButtonByText(dialog, "Cerrar"); + if (closeButton == nullptr) { + closeButton = QtModalTestUtils::findButtonByText(dialog, "Close"); + } + QVERIFY(closeButton != nullptr); + QTest::mouseClick(closeButton, Qt::LeftButton); +} + +void installLanguageTranslator(QTranslator& translator, const QString& langCode) { + QVERIFY(translator.load(langCode == "en" ? ":/translations/st_en.qm" + : ":/translations/st_es.qm")); + qApp->installTranslator(&translator); +} + +} // namespace + +void TutorWindowTest::mainInitialUiIsVisibleAndEnabled() { + clearTestAppSettings(); + + MainWindow window; + window.show(); + QCoreApplication::processEvents(); + + auto* llButton = window.findChild("pushButton"); + auto* slrButton = window.findChild("pushButton_2"); + auto* tutorialButton = window.findChild("tutorial"); + auto* languageButton = window.findChild("idiom"); + auto* scoreLabel = window.findChild("labelScore"); + auto* levelBadge = window.findChild("badgeNivel"); + auto* progressBar = window.findChild("progressBarNivel"); + + QVERIFY(llButton != nullptr && llButton->isEnabled()); + QVERIFY(slrButton != nullptr && slrButton->isEnabled()); + QVERIFY(tutorialButton != nullptr && tutorialButton->isEnabled()); + QVERIFY(languageButton != nullptr && languageButton->isEnabled()); + QVERIFY(scoreLabel != nullptr && scoreLabel->isVisible()); + QVERIFY(levelBadge != nullptr && levelBadge->isVisible()); + QVERIFY(progressBar != nullptr && progressBar->isVisible()); + QCOMPARE(levelBadge->text(), QString("1")); + QCOMPARE(scoreLabel->text(), QString("Puntos: 0")); + QCOMPARE(progressBar->value(), 0); +} + +void TutorWindowTest::mainSwitchLanguageToEnglishPersistsSelection() { + clearTestAppSettings(); + + MainWindow window; + window.show(); + + QtModalTestUtils::scheduleUntilHandled([]() { + QDialog* dialog = findInfoDialog(); + if (dialog == nullptr) { + return false; + } + auto* englishButton = dialog->findChild("languageEnglishButton"); + if (englishButton == nullptr) { + return false; + } + QTest::mouseClick(englishButton, Qt::LeftButton); + return true; + }); + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QTest::mouseClick(window.findChild("idiom"), Qt::LeftButton); + + QCOMPARE(testAppSettings().value("lang/language").toString(), QString("en")); + + QTranslator translator; + installLanguageTranslator(translator, "en"); + MainWindow reopened; + reopened.show(); + auto* difficultyTitle = reopened.findChild("difficultyTitle"); + QVERIFY(difficultyTitle != nullptr); + QVERIFY(!difficultyTitle->text().isEmpty()); + qApp->removeTranslator(&translator); +} + +void TutorWindowTest::mainSwitchLanguageToSpanishPersistsSelection() { + clearTestAppSettings(); + testAppSettings().setValue("lang/language", "en"); + + QTranslator translator; + installLanguageTranslator(translator, "en"); + MainWindow window; + window.show(); + + QtModalTestUtils::scheduleUntilHandled([]() { + QDialog* dialog = findInfoDialog(); + if (dialog == nullptr) { + return false; + } + auto* spanishButton = dialog->findChild("languageSpanishButton"); + if (spanishButton == nullptr) { + return false; + } + QTest::mouseClick(spanishButton, Qt::LeftButton); + return true; + }); + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QTest::mouseClick(window.findChild("idiom"), Qt::LeftButton); + + QCOMPARE(testAppSettings().value("lang/language").toString(), QString("es")); + qApp->removeTranslator(&translator); + + MainWindow reopened; + reopened.show(); + auto* difficultyTitle = reopened.findChild("difficultyTitle"); + QVERIFY(difficultyTitle != nullptr); + QCOMPARE(difficultyTitle->text(), QString("Dificultad")); +} + +void TutorWindowTest::mainAboutDialogShowsMetadata() { + clearTestAppSettings(); + + MainWindow window; + window.show(); + + auto* action = window.findChild("actionSobre_la_aplicaci_n"); + QVERIFY(action != nullptr); + bool contentVerified = false; + QtModalTestUtils::scheduleUntilHandled([&contentVerified]() { + QDialog* dialog = findInfoDialog(); + if (dialog == nullptr) { + return false; + } + auto* content = dialog->findChild("infoDialogContent"); + if (content == nullptr) { + return false; + } + const QString text = content->toPlainText(); + contentVerified = text.contains("José R.") && text.contains("GPLv3") && + text.contains("GitHub"); + clickInfoDialogClose(dialog); + return true; + }); + action->trigger(); + QVERIFY(contentVerified); +} + +void TutorWindowTest::mainQuickReferencesOpen() { + clearTestAppSettings(); + + MainWindow window; + window.show(); + + auto* llRef = window.findChild("actionReferencia_LL_1"); + auto* slrRef = window.findChild("actionReferencia_SLR_1"); + QVERIFY(llRef != nullptr); + QVERIFY(slrRef != nullptr); + + bool llSeen = false; + QtModalTestUtils::scheduleUntilHandled([&llSeen]() { + QDialog* dialog = findInfoDialog(); + if (dialog == nullptr) { + return false; + } + llSeen = dialog->windowTitle().contains("LL(1)"); + clickInfoDialogClose(dialog); + return true; + }); + llRef->trigger(); + QVERIFY(llSeen); + + bool slrSeen = false; + QtModalTestUtils::scheduleUntilHandled([&slrSeen]() { + QDialog* dialog = findInfoDialog(); + if (dialog == nullptr) { + return false; + } + slrSeen = dialog->windowTitle().contains("SLR(1)"); + clickInfoDialogClose(dialog); + return true; + }); + slrRef->trigger(); + QVERIFY(slrSeen); +} + +void TutorWindowTest::mainLlAndSlrEntryPointsOpenTutors() { + clearTestAppSettings(); + + MainWindow llWindow; + llWindow.show(); + QTest::mouseClick(llWindow.findChild("pushButton"), Qt::LeftButton); + QTRY_VERIFY(llWindow.findChild() != nullptr); + + MainWindow slrWindow; + slrWindow.show(); + QTest::mouseClick(slrWindow.findChild("pushButton_2"), Qt::LeftButton); + QTRY_VERIFY(slrWindow.findChild() != nullptr); +} + +void TutorWindowTest::mainTutorialFlowCompletesAndReenablesControls() { + clearTestAppSettings(); + + MainWindow window; + window.show(); + + auto* llButton = window.findChild("pushButton"); + auto* slrButton = window.findChild("pushButton_2"); + auto* tutorialButton = window.findChild("tutorial"); + QVERIFY(llButton != nullptr); + QVERIFY(slrButton != nullptr); + QVERIFY(tutorialButton != nullptr); + + QTest::mouseClick(tutorialButton, Qt::LeftButton); + QVERIFY(!llButton->isEnabled()); + QVERIFY(!slrButton->isEnabled()); + + bool sawLlTutor = false; + bool sawSlrTutor = false; + for (int guard = 0; guard < 40 && !llButton->isEnabled(); ++guard) { + if (window.findChild() != nullptr) { + sawLlTutor = true; + } + if (window.findChild() != nullptr) { + sawSlrTutor = true; + } + QPushButton* nextButton = findVisibleTutorialNextButton(); + QVERIFY(nextButton != nullptr); + QTest::mouseClick(nextButton, Qt::LeftButton); + QTest::qWait(20); + } + + QVERIFY(sawLlTutor); + QVERIFY(sawSlrTutor); + QVERIFY(llButton->isEnabled()); + QVERIFY(slrButton->isEnabled()); + QVERIFY(tutorialButton->isEnabled()); +} + +void TutorWindowTest::mainGamificationPersistsAcrossRestart() { + clearTestAppSettings(); + + MainWindow window; + window.show(); + QTest::mouseClick(window.findChild("pushButton"), Qt::LeftButton); + auto* tutor = window.findChild(); + QVERIFY(tutor != nullptr); + + QVERIFY(QMetaObject::invokeMethod(tutor, "exitRequested", Qt::DirectConnection, + Q_ARG(bool, true), Q_ARG(int, 12), Q_ARG(int, 0))); + + auto* levelBadge = window.findChild("badgeNivel"); + auto* scoreLabel = window.findChild("labelScore"); + auto* progressBar = window.findChild("progressBarNivel"); + QVERIFY(levelBadge != nullptr); + QVERIFY(scoreLabel != nullptr); + QVERIFY(progressBar != nullptr); + QCOMPARE(levelBadge->text(), QString("2")); + QCOMPARE(scoreLabel->text(), QString("Puntos: 2")); + QCOMPARE(progressBar->value(), 10); + + MainWindow reopened; + reopened.show(); + QCOMPARE(reopened.findChild("badgeNivel")->text(), QString("2")); + QCOMPARE(reopened.findChild("labelScore")->text(), QString("Puntos: 2")); +} + +void TutorWindowTest::mainStatePersistenceAcrossRestart() { + clearTestAppSettings(); + QSettings settings = testAppSettings(); + settings.setValue("gamification/level", 3); + settings.setValue("gamification/score", 7); + settings.setValue("lang/language", "es"); + settings.sync(); + + MainWindow window; + window.show(); + + QCOMPARE(window.findChild("badgeNivel")->text(), QString("3")); + QCOMPARE(window.findChild("labelScore")->text(), QString("Puntos: 7")); + QCOMPARE(window.findChild("progressBarNivel")->value(), 23); +} diff --git a/tests/tutor/slr_tutor_window_test.cpp b/tests/tutor/slr_tutor_window_test.cpp new file mode 100644 index 00000000..b29bdd96 --- /dev/null +++ b/tests/tutor/slr_tutor_window_test.cpp @@ -0,0 +1,483 @@ +#include "tutor_window_test.h" + +#include "qt_modal_test_utils.h" +#include "slr_tutor_test_utils.h" +#include "slrtutorwindow.h" +#include "tutor_grammar_fixtures.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QPushButton* waitForTutorButton(SLRTutorWindow& tutor, const QString& objectName) { + const int intervalMs = 20; + int elapsed = 0; + + while (elapsed <= 2000) { + if (auto* button = tutor.findChild(objectName)) { + return button; + } + QTest::qWait(intervalMs); + elapsed += intervalMs; + } + + return nullptr; +} + +SLRTableDialog* waitForSlrTableDialog() { + return QtModalTestUtils::waitForVisibleTopLevelWidget(); +} + +QWizard* waitForWizard() { + return QtModalTestUtils::waitForVisibleTopLevelWidget(); +} + +void driveSlrTutorToState(SLRTutorWindow& tutor, const QString& state) { + SlrTutorTestUtils::driveTutorToState(tutor, state); +} + +void driveSlrTutorToH(SLRTutorWindow& tutor) { + driveSlrTutorToState(tutor, "H"); + QVERIFY(waitForSlrTableDialog() != nullptr); +} + +void driveSlrTutorUntilCbSymbol(SLRTutorWindow& tutor, const QString& symbol) { + for (int guard = 0; guard < 200; ++guard) { + const QString state = tutor.currentStateForTest(); + if (state == "CB" && tutor.currentCbSymbolForTest() == symbol) { + return; + } + if (state == "D" || state == "H" || state == "fin") { + break; + } + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + } + QCOMPARE(tutor.currentStateForTest(), QString("CB")); + QCOMPARE(tutor.currentCbSymbolForTest(), symbol); +} + +void driveSlrTutorToCbWithNonEpsilonSymbol(SLRTutorWindow& tutor) { + for (int guard = 0; guard < 200; ++guard) { + const QString state = tutor.currentStateForTest(); + if (state == "CB" && tutor.currentCbSymbolForTest() != "EPSILON") { + return; + } + if (state == "D" || state == "H" || state == "fin") { + break; + } + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + } + QCOMPARE(tutor.currentStateForTest(), QString("CB")); + QVERIFY(tutor.currentCbSymbolForTest() != "EPSILON"); +} + +} // namespace + +void TutorWindowTest::slrCreatesTutorWithNullTutorialManager() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + + QCOMPARE(tutor.currentStateForTest(), QString("A")); + QCOMPARE(tutor.rightCountForTest(), 0); + QCOMPARE(tutor.wrongCountForTest(), 0); +} + +void TutorWindowTest::slrStateAErrorPathAdvancesThroughAprime() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + + tutor.setAnswerForTest("X -> .x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A1")); + + tutor.setAnswerForTest("X"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A1")); + + tutor.setAnswerForTest(tutor.solutionForA1()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A2")); + + tutor.setAnswerForTest("X"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A2")); + + tutor.setAnswerForTest(tutor.solutionForA2()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A3")); + + tutor.setAnswerForTest("X -> x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A3")); + + tutor.setAnswerForTest(SlrTutorTestUtils::rulesAnswer(tutor.solutionForA3())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A4")); + + tutor.setAnswerForTest("X -> .x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A4")); + + tutor.setAnswerForTest(SlrTutorTestUtils::itemsAnswer(tutor.solutionForA4())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A'")); + + tutor.setAnswerForTest("X -> .x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); +} + +void TutorWindowTest::slrStateACorrectPathAdvancesToB() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); + + QCOMPARE(tutor.currentStateForTest(), QString("B")); + QCOMPARE(tutor.rightCountForTest(), 1); + QCOMPARE(tutor.wrongCountForTest(), 0); +} + +void TutorWindowTest::slrStateBAndCIncorrectAnswersStillAdvance() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("C")); + QCOMPARE(tutor.wrongCountForTest(), 1); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("CA")); + QCOMPARE(tutor.wrongCountForTest(), 2); +} + +void TutorWindowTest::slrStateCAWrongThenCorrect() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("CA")); + + tutor.setAnswerForTest("z"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("CA")); + QCOMPARE(tutor.wrongCountForTest(), 1); + + const QString nextState = + SlrTutorTestUtils::sortedSymbolsAnswer(tutor.solutionForCA()).isEmpty() + ? QString("B") + : QString("CB"); + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), nextState); +} + +void TutorWindowTest::slrStateCBEpsilonBranchAcceptsOnlyEmpty() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorUntilCbSymbol(tutor, "EPSILON"); + + tutor.setAnswerForTest("x"); + tutor.submitForTest(); + QCOMPARE(tutor.wrongCountForTest(), 1); + QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + + SLRTutorWindow tutor2(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor2); + driveSlrTutorUntilCbSymbol(tutor2, "EPSILON"); + const int rightBefore = tutor2.rightCountForTest(); + const int wrongBefore = tutor2.wrongCountForTest(); + tutor2.setAnswerForTest(QString()); + tutor2.submitForTest(); + QCOMPARE(tutor2.rightCountForTest(), rightBefore + 1); + QCOMPARE(tutor2.wrongCountForTest(), wrongBefore); +} + +void TutorWindowTest::slrStateCBNonEpsilonBranchAdvancesOnWrongAndCorrect() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToCbWithNonEpsilonSymbol(tutor); + + tutor.setAnswerForTest("1"); + tutor.submitForTest(); + QCOMPARE(tutor.wrongCountForTest(), 1); + QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + + SLRTutorWindow tutor2(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor2); + driveSlrTutorToCbWithNonEpsilonSymbol(tutor2); + tutor2.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor2)); + tutor2.submitForTest(); + QVERIFY(tutor2.currentStateForTest() == "CB" || tutor2.currentStateForTest() == "B"); +} + +void TutorWindowTest::slrDriveThroughCollectionUntilD() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "D"); + QCOMPARE(tutor.currentStateForTest(), QString("D")); +} + +void TutorWindowTest::slrStateDErrorPathAdvancesToE() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "D"); + + tutor.setAnswerForTest("1,1"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("D1")); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("D1")); + + tutor.setAnswerForTest(tutor.solutionForD1()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("D2")); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("D2")); + + tutor.setAnswerForTest(tutor.solutionForD2()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("D'")); + + tutor.setAnswerForTest("1,1"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("E")); +} + +void TutorWindowTest::slrStateEErrorPathAdvancesToF() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "E"); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("E1")); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("E1")); + + tutor.setAnswerForTest(SlrTutorTestUtils::idSetAnswer(tutor.solutionForE1())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("E2")); + + tutor.setAnswerForTest("999:1"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("F")); +} + +void TutorWindowTest::slrStateFNoConflictAdvancesToG() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "F"); + QCOMPARE(tutor.solutionForF().isEmpty(), true); + + tutor.setAnswerForTest(QString()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("G")); +} + +void TutorWindowTest::slrStateFConflictBranchAdvancesToFAAndThenG() { + const Grammar grammar = TutorGrammarFixtures::makeSlrConflictGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "F"); + QVERIFY(!tutor.solutionForF().isEmpty()); + + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("F")); + + tutor.setAnswerForTest(SlrTutorTestUtils::idSetAnswer(tutor.solutionForF())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("FA")); + + tutor.setAnswerForTest("x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("FA")); + + while (tutor.currentStateForTest() == "FA") { + tutor.setAnswerForTest(SlrTutorTestUtils::stringSetAnswer(tutor.solutionForFA())); + tutor.submitForTest(); + } + QCOMPARE(tutor.currentStateForTest(), QString("G")); +} + +void TutorWindowTest::slrStateGWrongThenCorrectReachesH() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "G"); + + tutor.setAnswerForTest("x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("G")); + + driveSlrTutorToH(tutor); +} + +void TutorWindowTest::slrStateHIncorrectTableKeepsDialogOpen() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToH(tutor); + + SLRTableDialog* dialog = waitForSlrTableDialog(); + auto* table = dialog->findChild("slrTableWidget"); + QVERIFY(table != nullptr); + + const QVector> wrongTable = + SlrTutorTestUtils::buildWrongTable(grammar, table); + const int wrongRow = QtModalTestUtils::firstNonEmptyCellRow(wrongTable); + const int wrongCol = QtModalTestUtils::firstNonEmptyCellCol(wrongTable); + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Ok); + QtModalTestUtils::submitSlrTableDialog(dialog, wrongTable); + + QCOMPARE(tutor.currentStateForTest(), QString("H")); + QCOMPARE(tutor.wrongCountForTest(), 0); + QCOMPARE(QtModalTestUtils::cellBackground(table, wrongRow, wrongCol), + QColor("#d9534f")); + QVERIFY(dialog->isVisible()); +} + +void TutorWindowTest::slrGuidedModeWizardCompletesAndReturnsToTable() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToH(tutor); + + SLRTableDialog* dialog = waitForSlrTableDialog(); + auto* table = dialog->findChild("slrTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::requestSlrGuidedMode( + dialog, QVector>(table->rowCount(), + QVector(table->columnCount()))); + QWizard* wizard = waitForWizard(); + QVERIFY(wizard != nullptr); + QPointer wizardGuard(wizard); + + SlrTutorTestUtils::finishWizard(wizard); + QTRY_VERIFY(wizardGuard == nullptr || !wizardGuard->isVisible()); + QVERIFY(dialog->isVisible()); + QCOMPARE(tutor.currentStateForTest(), QString("H")); +} + +void TutorWindowTest::slrTableDialogCancelNoReopensAndYesRequestsExit() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + QSignalSpy exitSpy(&tutor, &SLRTutorWindow::exitRequested); + + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToH(tutor); + + SLRTableDialog* dialog = waitForSlrTableDialog(); + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::No); + dialog->reject(); + + SLRTableDialog* reopenedDialog = waitForSlrTableDialog(); + QVERIFY(reopenedDialog != nullptr); + QVERIFY(reopenedDialog != dialog); + QCOMPARE(exitSpy.count(), 0); + + QtModalTestUtils::scheduleMessageBoxResponse(QMessageBox::Yes); + reopenedDialog->reject(); + + QTRY_COMPARE(exitSpy.count(), 1); + QCOMPARE(exitSpy.takeFirst().at(0).toBool(), false); +} + +void TutorWindowTest::slrFinalTableCorrectPathExportsAndExits() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + QSignalSpy exitSpy(&tutor, &SLRTutorWindow::exitRequested); + + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToH(tutor); + + SLRTableDialog* dialog = waitForSlrTableDialog(); + auto* table = dialog->findChild("slrTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::submitSlrTableDialog( + dialog, SlrTutorTestUtils::buildExpectedTable(grammar, table)); + + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString pdfPath = tempDir.filePath("slr_tutor_test.pdf"); + + tutor.setNextExportFilePathForTest(pdfPath); + auto* exportButton = waitForTutorButton(tutor, "slrTutorExportPdfButton"); + QVERIFY(exportButton != nullptr); + QTest::mouseClick(exportButton, Qt::LeftButton); + QTRY_VERIFY(QFileInfo::exists(pdfPath)); + QVERIFY(QFileInfo(pdfPath).size() > 0); + + auto* exitButton = waitForTutorButton(tutor, "slrTutorExitButton"); + QVERIFY(exitButton != nullptr); + QTest::mouseClick(exitButton, Qt::LeftButton); + QTRY_COMPARE(exitSpy.count(), 1); + QCOMPARE(exitSpy.takeFirst().at(0).toBool(), true); +} + +void TutorWindowTest::slrSmokeGuiFindsCoreWidgets() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + SLRTutorWindow tutor(grammar, nullptr); + tutor.show(); + QCoreApplication::processEvents(); + + QVERIFY(tutor.findChild("listWidget") != nullptr); + QVERIFY(tutor.findChild("userResponse") != nullptr); + QVERIFY(tutor.findChild("confirmButton") != nullptr); + QVERIFY(tutor.findChild("backButton") != nullptr); + + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); +} diff --git a/tests/tutor/tutor_test_main.cpp b/tests/tutor/tutor_test_main.cpp new file mode 100644 index 00000000..06db8edf --- /dev/null +++ b/tests/tutor/tutor_test_main.cpp @@ -0,0 +1,10 @@ +#include "tutor_window_test.h" + +#include +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + TutorWindowTest test; + return QTest::qExec(&test, argc, argv); +} diff --git a/tests/tutor/tutor_window_test.h b/tests/tutor/tutor_window_test.h new file mode 100644 index 00000000..e5494fd1 --- /dev/null +++ b/tests/tutor/tutor_window_test.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +class TutorWindowTest : public QObject { + Q_OBJECT + + private slots: + void createsTutorWithNullTutorialManager(); + void stateAErrorPathAdvancesThroughAStates(); + void stateACorrectPathAdvancesToB(); + void stateBAxiomBranchSkipsB2(); + void stateBDirectAndFallbackPathsUpdateCounters(); + void stateBWrongAnswersStayInB1AndB2(); + void stateCCorrectPathOpensExportActions(); + void stateCWrongAttemptsReachCPrimeAndRecover(); + void tableDialogCancelNoReopensAndYesRequestsExit(); + void tableDialogCancelInCPrimeNoReopensAndYesRequestsExit(); + void exportButtonExportsPdf(); + void exitButtonFinishesWithoutExport(); + void exportsConversationToPdf(); + void smokeGuiFindsCoreWidgets(); + + void slrCreatesTutorWithNullTutorialManager(); + void slrStateAErrorPathAdvancesThroughAprime(); + void slrStateACorrectPathAdvancesToB(); + void slrStateBAndCIncorrectAnswersStillAdvance(); + void slrStateCAWrongThenCorrect(); + void slrStateCBEpsilonBranchAcceptsOnlyEmpty(); + void slrStateCBNonEpsilonBranchAdvancesOnWrongAndCorrect(); + void slrDriveThroughCollectionUntilD(); + void slrStateDErrorPathAdvancesToE(); + void slrStateEErrorPathAdvancesToF(); + void slrStateFNoConflictAdvancesToG(); + void slrStateFConflictBranchAdvancesToFAAndThenG(); + void slrStateGWrongThenCorrectReachesH(); + void slrStateHIncorrectTableKeepsDialogOpen(); + void slrGuidedModeWizardCompletesAndReturnsToTable(); + void slrTableDialogCancelNoReopensAndYesRequestsExit(); + void slrFinalTableCorrectPathExportsAndExits(); + void slrSmokeGuiFindsCoreWidgets(); + + void mainInitialUiIsVisibleAndEnabled(); + void mainSwitchLanguageToEnglishPersistsSelection(); + void mainSwitchLanguageToSpanishPersistsSelection(); + void mainAboutDialogShowsMetadata(); + void mainQuickReferencesOpen(); + void mainLlAndSlrEntryPointsOpenTutors(); + void mainTutorialFlowCompletesAndReenablesControls(); + void mainGamificationPersistsAcrossRestart(); + void mainStatePersistenceAcrossRestart(); +}; From 0216a247510baeaf05dfda4d787c43eef2bc281c Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 17:23:05 +0200 Subject: [PATCH 10/12] test: support list of fixtures --- .../grammars/tutor_grammar_fixtures.cpp | 35 +++++++++++++++++++ .../grammars/tutor_grammar_fixtures.h | 16 +++++++++ 2 files changed, 51 insertions(+) diff --git a/tests/fixtures/grammars/tutor_grammar_fixtures.cpp b/tests/fixtures/grammars/tutor_grammar_fixtures.cpp index 3940ccd7..979d3c24 100644 --- a/tests/fixtures/grammars/tutor_grammar_fixtures.cpp +++ b/tests/fixtures/grammars/tutor_grammar_fixtures.cpp @@ -12,12 +12,47 @@ Grammar makeLl1EpsilonGrammar() { return Grammar({{"A", {{"B"}}}, {"B", {{"b"}, {"EPSILON"}}}}); } +Grammar makeLl1BranchingGrammar() { + return Grammar({{"A", {{"a", "B"}, {"b", "C"}}}, + {"B", {{"c"}, {"d"}}}, + {"C", {{"e"}}}}); +} + Grammar makeSlrSimpleGrammar() { return Grammar({{"A", {{"a", "A"}, {"b"}}}}); } +Grammar makeSlrChainGrammar() { + return Grammar({{"A", {{"a", "B"}, {"b"}}}, {"B", {{"c"}}}}); +} + Grammar makeSlrConflictGrammar() { return Grammar({{"A", {{"a"}, {"a", "A"}}}}); } +QList llFixtures() { + return {{"ll-simple", makeLl1SimpleGrammar()}, + {"ll-epsilon", makeLl1EpsilonGrammar()}, + {"ll-branching", makeLl1BranchingGrammar()}}; +} + +QList slrNoConflictFixtures() { + return {{"slr-simple", makeSlrSimpleGrammar()}, + {"slr-chain", makeSlrChainGrammar()}}; +} + +QList slrConflictFixtures() { + return {{"slr-conflict", makeSlrConflictGrammar()}}; +} + +QList slrFixturesWithCbEpsilon() { + return {{"slr-simple", makeSlrSimpleGrammar()}, + {"slr-chain", makeSlrChainGrammar()}}; +} + +QList slrFixturesWithCbNonEpsilon() { + return {{"slr-simple", makeSlrSimpleGrammar()}, + {"slr-chain", makeSlrChainGrammar()}}; +} + } // namespace TutorGrammarFixtures diff --git a/tests/fixtures/grammars/tutor_grammar_fixtures.h b/tests/fixtures/grammars/tutor_grammar_fixtures.h index 5067738f..fba844f2 100644 --- a/tests/fixtures/grammars/tutor_grammar_fixtures.h +++ b/tests/fixtures/grammars/tutor_grammar_fixtures.h @@ -2,11 +2,27 @@ #include "grammar.hpp" +#include +#include + namespace TutorGrammarFixtures { +struct NamedGrammarFixture { + QString name; + Grammar grammar; +}; + Grammar makeLl1SimpleGrammar(); Grammar makeLl1EpsilonGrammar(); +Grammar makeLl1BranchingGrammar(); Grammar makeSlrSimpleGrammar(); +Grammar makeSlrChainGrammar(); Grammar makeSlrConflictGrammar(); +QList llFixtures(); +QList slrNoConflictFixtures(); +QList slrConflictFixtures(); +QList slrFixturesWithCbEpsilon(); +QList slrFixturesWithCbNonEpsilon(); + } // namespace TutorGrammarFixtures From d9ada4643fb50b1fd48447792cfe1d911ca64152 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 17:23:24 +0200 Subject: [PATCH 11/12] test: support list of fixtures and docs --- tests/tutor/ll_tutor_window_test.cpp | 493 ++++++++++++++--- tests/tutor/main_window_test.cpp | 145 +++++ tests/tutor/slr_tutor_window_test.cpp | 754 +++++++++++++++++++++----- tests/tutor/tutor_window_test.h | 4 + 4 files changed, 1175 insertions(+), 221 deletions(-) diff --git a/tests/tutor/ll_tutor_window_test.cpp b/tests/tutor/ll_tutor_window_test.cpp index 7dd644e8..42701273 100644 --- a/tests/tutor/ll_tutor_window_test.cpp +++ b/tests/tutor/ll_tutor_window_test.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -15,8 +16,52 @@ #include #include +#include + namespace { +template void forEachLlFixture(Fn&& fn) { + for (const auto& fixture : TutorGrammarFixtures::llFixtures()) { + qInfo().noquote() << "LL fixture:" << fixture.name; + fn(fixture); + } +} + +QString spacedCommaVariant(const QString& answer) { + QStringList parts = answer.split(',', Qt::SkipEmptyParts); + std::reverse(parts.begin(), parts.end()); + for (QString& part : parts) { + part = QString(" %1 ").arg(part.trimmed()); + } + return parts.join(", "); +} + +QString spacedTableSizeVariant(const QString& answer) { + const QStringList parts = answer.split(',', Qt::KeepEmptyParts); + if (parts.size() != 2) { + return answer; + } + return QString(" %1 , %2 ").arg(parts.at(0).trimmed(), parts.at(1).trimmed()); +} + +QVector> flexibleLlTable(const QVector>& raw) { + QVector> formatted = raw; + for (int row = 0; row < formatted.size(); ++row) { + for (int col = 0; col < formatted[row].size(); ++col) { + QString& cell = formatted[row][col]; + if (cell.isEmpty()) { + continue; + } + if (cell.contains(' ')) { + cell = QString(" %1 ").arg(cell.replace(' ', " ")); + } else { + cell = QString(" %1 ").arg(cell); + } + } + } + return formatted; +} + void answerRemainingRulesUntilStateC(LLTutorWindow& tutor, const Grammar& grammar) { while (tutor.currentStateForTest() == "B") { tutor.setAnswerForTest(Ll1TutorTestUtils::predictionSymbolsAnswer( @@ -71,40 +116,105 @@ QPushButton* waitForTutorButton(LLTutorWindow& tutor, const QString& objectName) } // namespace +// ----------------------------------------------------------------------------- +// Case: LL1-TC-01 +// Summary: +// Verifies that an LL(1) session can be opened with a fixed grammar and +// without guided tutorial mode. +// +// Situation: +// LL(1) tutor freshly created with a valid LL fixture and `tm=nullptr`. +// +// Action: +// The test instantiates the tutor without any further interaction. +// +// Expected: +// The tutor starts in state A and both counters begin at zero. +// ----------------------------------------------------------------------------- void TutorWindowTest::createsTutorWithNullTutorialManager() { - const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); - - LLTutorWindow tutor(grammar, nullptr); + forEachLlFixture([](const auto& fixture) { + LLTutorWindow tutor(fixture.grammar, nullptr); - QCOMPARE(tutor.currentStateForTest(), QString("A")); - QCOMPARE(tutor.rightCountForTest(), 0); - QCOMPARE(tutor.wrongCountForTest(), 0); + QCOMPARE(tutor.currentStateForTest(), QString("A")); + QCOMPARE(tutor.rightCountForTest(), 0); + QCOMPARE(tutor.wrongCountForTest(), 0); + }); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-02 +// Summary: +// Exercises the full error path from state A until the tutor returns to the +// size question and then moves into block B. +// +// Situation: +// LL(1) tutor at startup, using several general-purpose LL fixtures. +// +// Action: +// The user misses the table size, misses and fixes the non-terminal and +// terminal counts, and then misses the final size prompt again. +// +// Expected: +// The tutor advances through A1, A2 and A', updates the counters, and enters +// B at the end. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateAErrorPathAdvancesThroughAStates() { - const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); - - LLTutorWindow tutor(grammar, nullptr); - - runScenario(tutor, - {{"1,1", "A1", 0, 1}, - {"99", "A1", 0, 2}, - {Ll1TutorTestUtils::nonTerminalCountAnswer(grammar), "A2", 1, - 2}, - {"99", "A2", 1, 3}, - {Ll1TutorTestUtils::terminalCountAnswer(grammar), "A'", 2, 3}, - {"1,1", "B", 2, 4}}); + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, + {{"1,1", "A1", 0, 1}, + {"99", "A1", 0, 2}, + {Ll1TutorTestUtils::nonTerminalCountAnswer(grammar), "A2", 1, + 2}, + {"99", "A2", 1, 3}, + {Ll1TutorTestUtils::terminalCountAnswer(grammar), "A'", 2, 3}, + {"1,1", "B", 2, 4}}); + }); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-03 +// Summary: +// Checks the direct happy path when the user gets the LL(1) table size right +// on the first try. +// +// Situation: +// LL(1) tutor freshly opened with several general-purpose LL fixtures. +// +// Action: +// The user enters the correct table size immediately. +// +// Expected: +// State A is resolved without visiting A1/A2 and the tutor enters B. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateACorrectPathAdvancesToB() { - const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); - - LLTutorWindow tutor(grammar, nullptr); + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); - runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, - 0}}); + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + }); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-05 +// Summary: +// Validates the axiom-specific branch in phase B, where the tutor skips the +// FOLLOW question. +// +// Situation: +// LL(1) tutor in B on the axiom rule from the simple fixture. +// +// Action: +// The user misses the prediction symbols and then answers the FIRST/CAB set +// for the axiom rule correctly. +// +// Expected: +// The tutor enters B1 and then jumps directly to B' without visiting B2. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateBAxiomBranchSkipsB2() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -123,6 +233,23 @@ void TutorWindowTest::stateBAxiomBranchSkipsB2() { "B'", 2, 1}}); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-04, LL1-TC-06 +// Summary: +// Covers both the direct correct answer in B and the full support branch for a +// non-axiom rule. +// +// Situation: +// LL(1) tutor already in B after a correct resolution of block A. +// +// Action: +// The user first answers one rule directly, then misses SD on another rule and +// fixes CAB and SIG before returning to SD. +// +// Expected: +// The counters reflect right and wrong answers, and the flow walks through B, +// B1, B2 and B' correctly. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateBDirectAndFallbackPathsUpdateCounters() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -150,6 +277,23 @@ void TutorWindowTest::stateBDirectAndFallbackPathsUpdateCounters() { {"x", "B", 4, 2}}); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-04 +// Summary: +// Checks that B1 and B2 keep the user on the same step while the answer stays +// wrong. +// +// Situation: +// LL(1) tutor on a non-axiom rule after the first B step for that rule has +// already been resolved. +// +// Action: +// The user fails twice in B1, fixes CAB, fails in B2 and then fixes SIG. +// +// Expected: +// The tutor stays in B1 and B2 when appropriate and only advances once the +// answer is corrected. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateBWrongAnswersStayInB1AndB2() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -177,6 +321,22 @@ void TutorWindowTest::stateBWrongAnswersStayInB1AndB2() { "B'", 4, 3}}); } +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Verifies PDF export of the conversation without depending on the tutor's +// final flow. +// +// Situation: +// LL(1) tutor using a fixture with epsilon and an already initialized +// conversation. +// +// Action: +// The test exports directly to a temporary path. +// +// Expected: +// A PDF is generated, it exists, and it is not empty. +// ----------------------------------------------------------------------------- void TutorWindowTest::exportsConversationToPdf() { const Grammar grammar = TutorGrammarFixtures::makeLl1EpsilonGrammar(); @@ -195,28 +355,61 @@ void TutorWindowTest::exportsConversationToPdf() { QVERIFY(pdfInfo.size() > 0); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-07 +// Summary: +// Checks that a correct LL(1) table reaches the final state and exposes the +// tutor's final actions. +// +// Situation: +// LL(1) tutor in state C with the table dialog open. +// +// Action: +// The user fills the table correctly and presses `Finalizar`. +// +// Expected: +// The tutor enters `fin` and the `Exportar PDF` and `Salir` buttons appear. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateCCorrectPathOpensExportActions() { - const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); - - LLTutorWindow tutor(grammar, nullptr); - - 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(waitForTutorButton(tutor, "llTutorExportPdfButton") != nullptr); - QVERIFY(waitForTutorButton(tutor, "llTutorExitButton") != nullptr); + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); + + 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(waitForTutorButton(tutor, "llTutorExportPdfButton") != nullptr); + QVERIFY(waitForTutorButton(tutor, "llTutorExitButton") != nullptr); + }); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-08 +// Summary: +// Exercises the LL(1) table retry limit, the transition into C', and final +// recovery with a correct table. +// +// Situation: +// LL(1) tutor in C with the table dialog open on the simple fixture. +// +// Action: +// The user submits several wrong tables until the retry limit is reached, +// fails again in C', and finally fixes the table. +// +// Expected: +// The expected messages and highlights appear, the tutor enters C' at the +// limit, and reaches `fin` when the table is corrected. +// ----------------------------------------------------------------------------- void TutorWindowTest::stateCWrongAttemptsReachCPrimeAndRecover() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -276,6 +469,23 @@ void TutorWindowTest::stateCWrongAttemptsReachCPrimeAndRecover() { QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-09 +// Summary: +// Validates cancellation of the table dialog in C, both when continuing and +// when leaving the tutor. +// +// Situation: +// LL(1) tutor in C with the table dialog open. +// +// Action: +// The user closes the dialog, answers `No` to the confirmation, closes it +// again, and then answers `Yes`. +// +// Expected: +// The dialog first reopens and the flow continues; then the tutor emits the +// exit request. +// ----------------------------------------------------------------------------- void TutorWindowTest::tableDialogCancelNoReopensAndYesRequestsExit() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -306,6 +516,22 @@ void TutorWindowTest::tableDialogCancelNoReopensAndYesRequestsExit() { QCOMPARE(arguments.at(0).toBool(), false); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-09 +// Summary: +// Repeats dialog cancellation validation after the tutor has already moved +// into state C'. +// +// Situation: +// LL(1) tutor in C' after exhausting the allowed table attempts. +// +// Action: +// The user closes the dialog, first continues, and then confirms exit. +// +// Expected: +// The dialog reopens when appropriate and the tutor requests exit when the +// user confirms. +// ----------------------------------------------------------------------------- void TutorWindowTest::tableDialogCancelInCPrimeNoReopensAndYesRequestsExit() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -341,37 +567,68 @@ void TutorWindowTest::tableDialogCancelInCPrimeNoReopensAndYesRequestsExit() { QCOMPARE(arguments.at(0).toBool(), false); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-07 +// Summary: +// Verifies real PDF export from the final LL(1) tutor action. +// +// Situation: +// LL(1) tutor already in the final state after solving the table correctly. +// +// Action: +// The user presses `Exportar PDF` and the test injects a temporary path. +// +// Expected: +// A valid non-empty PDF is created at the selected path. +// ----------------------------------------------------------------------------- void TutorWindowTest::exportButtonExportsPdf() { - const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); - LLTutorWindow tutor(grammar, nullptr); + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); - runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, - 0}}); - answerRemainingRulesUntilStateC(tutor, grammar); - - LLTableDialog* dialog = waitForTableDialog(); - auto* table = dialog->findChild("llTableWidget"); - QVERIFY(table != nullptr); + LLTableDialog* dialog = waitForTableDialog(); + auto* table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); - QtModalTestUtils::submitLlTableDialog( - dialog, QtModalTestUtils::buildExpectedTable(grammar, table)); + QtModalTestUtils::submitLlTableDialog( + dialog, QtModalTestUtils::buildExpectedTable(grammar, table)); - QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); - QTemporaryDir tempDir; - QVERIFY(tempDir.isValid()); - const QString pdfPath = tempDir.filePath("ll_export_via_dialog.pdf"); + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString pdfPath = tempDir.filePath( + QString("%1-export.pdf").arg(fixture.name)); - tutor.setNextExportFilePathForTest(pdfPath); - auto* exportButton = waitForTutorButton(tutor, "llTutorExportPdfButton"); - QTest::mouseClick(exportButton, Qt::LeftButton); + tutor.setNextExportFilePathForTest(pdfPath); + auto* exportButton = waitForTutorButton(tutor, "llTutorExportPdfButton"); + QTest::mouseClick(exportButton, Qt::LeftButton); - QTRY_VERIFY(QFileInfo::exists(pdfPath)); - QFileInfo pdfInfo(pdfPath); - QVERIFY(pdfInfo.size() > 0); + QTRY_VERIFY(QFileInfo::exists(pdfPath)); + QFileInfo pdfInfo(pdfPath); + QVERIFY(pdfInfo.size() > 0); + }); } +// ----------------------------------------------------------------------------- +// Case: LL1-TC-07 +// Summary: +// Checks the clean exit path from the finished tutor without exporting the +// conversation. +// +// Situation: +// LL(1) tutor already in `fin`, with the final actions visible. +// +// Action: +// The user presses `Salir`. +// +// Expected: +// The tutor emits an exit request while applying the session results. +// ----------------------------------------------------------------------------- void TutorWindowTest::exitButtonFinishesWithoutExport() { const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); @@ -400,19 +657,107 @@ void TutorWindowTest::exitButtonFinishesWithoutExport() { QCOMPARE(arguments.at(0).toBool(), true); } +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Minimal visual smoke test to ensure the LL(1) window shows its essential +// widgets and accepts a basic answer. +// +// Situation: +// LL(1) tutor open with several general-purpose fixtures. +// +// Action: +// The test shows the window, finds key widgets, and submits one simple correct +// answer. +// +// Expected: +// The UI appears without crashing and the tutor advances from A to B. +// ----------------------------------------------------------------------------- void TutorWindowTest::smokeGuiFindsCoreWidgets() { - const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); + tutor.show(); + QCoreApplication::processEvents(); + + QVERIFY(tutor.findChild("listWidget") != nullptr); + QVERIFY(tutor.findChild("userResponse") != nullptr); + QVERIFY(tutor.findChild("confirmButton") != nullptr); + QVERIFY(tutor.findChild("backButton") != nullptr); + + tutor.setAnswerForTest(Ll1TutorTestUtils::tableSizeAnswer(grammar)); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + }); +} - LLTutorWindow tutor(grammar, nullptr); - tutor.show(); - QCoreApplication::processEvents(); +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Ensures that the LL(1) tutor accepts reasonable formatting variants in user +// input when the answer is otherwise correct. +// +// Situation: +// LL(1) tutor using several general fixtures for table-size and set-based +// questions. +// +// Action: +// The user answers with additional spaces and common typing variants around +// commas. +// +// Expected: +// Correct answers are still accepted and the flow continues. +// ----------------------------------------------------------------------------- +void TutorWindowTest::llAcceptsFlexibleUserFormatting() { + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); + + tutor.setAnswerForTest( + spacedTableSizeVariant(Ll1TutorTestUtils::tableSizeAnswer(grammar))); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); - QVERIFY(tutor.findChild("listWidget") != nullptr); - QVERIFY(tutor.findChild("userResponse") != nullptr); - QVERIFY(tutor.findChild("confirmButton") != nullptr); - QVERIFY(tutor.findChild("backButton") != nullptr); + const QString prediction = Ll1TutorTestUtils::predictionSymbolsAnswer( + grammar, tutor.currentRuleAntecedentForTest(), + tutor.currentRuleConsequentForTest()); + tutor.setAnswerForTest(spacedCommaVariant(prediction)); + tutor.submitForTest(); + QVERIFY(tutor.rightCountForTest() >= 2); + }); +} - tutor.setAnswerForTest(Ll1TutorTestUtils::tableSizeAnswer(grammar)); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("B")); +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Checks that the LL(1) table accepts correct content even when the user types +// extra spaces inside the cells. +// +// Situation: +// LL(1) tutor in C with the table dialog open for several fixtures. +// +// Action: +// The user fills the correct table with extra padding and spacing. +// +// Expected: +// The table is still accepted and the tutor reaches the final state. +// ----------------------------------------------------------------------------- +void TutorWindowTest::llAcceptsFlexibleTableCellFormatting() { + forEachLlFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + LLTutorWindow tutor(grammar, nullptr); + + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", 1, + 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); + + LLTableDialog* dialog = waitForTableDialog(); + auto* table = dialog->findChild("llTableWidget"); + QVERIFY(table != nullptr); + + const QVector> expected = + QtModalTestUtils::buildExpectedTable(grammar, table); + QtModalTestUtils::submitLlTableDialog(dialog, flexibleLlTable(expected)); + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + }); } diff --git a/tests/tutor/main_window_test.cpp b/tests/tutor/main_window_test.cpp index 8248f44c..f78587a4 100644 --- a/tests/tutor/main_window_test.cpp +++ b/tests/tutor/main_window_test.cpp @@ -68,6 +68,23 @@ void installLanguageTranslator(QTranslator& translator, const QString& langCode) } // namespace +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-01 +// Summary: +// Verifies that the main window appears correctly with its primary entry +// controls and gamification indicators visible. +// +// Situation: +// Application freshly opened on the home screen with empty test settings. +// +// Action: +// The test shows `MainWindow` and locates the main buttons, progress bar, +// level badge, and score label. +// +// Expected: +// The window is visible without errors, the controls are enabled, and the +// initial state is consistent. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainInitialUiIsVisibleAndEnabled() { clearTestAppSettings(); @@ -95,6 +112,23 @@ void TutorWindowTest::mainInitialUiIsVisibleAndEnabled() { QCOMPARE(progressBar->value(), 0); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-02 +// Summary: +// Checks the language switch to English and persistence of the selected +// preference. +// +// Situation: +// Main window open in the default language and using isolated test settings. +// +// Action: +// The user opens the language selector, chooses English, and confirms the +// informational message. +// +// Expected: +// The `en` selection is stored and a newly opened window can use that saved +// preference. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainSwitchLanguageToEnglishPersistsSelection() { clearTestAppSettings(); @@ -128,6 +162,23 @@ void TutorWindowTest::mainSwitchLanguageToEnglishPersistsSelection() { qApp->removeTranslator(&translator); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-03 +// Summary: +// Checks the language switch back to Spanish from a state where English was +// previously selected. +// +// Situation: +// Main window open with the `en` preference already stored in test settings. +// +// Action: +// The user opens the language selector, chooses Spanish, and confirms the +// informational message. +// +// Expected: +// The `es` selection is stored and a newly opened window shows the main text +// in Spanish again. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainSwitchLanguageToSpanishPersistsSelection() { clearTestAppSettings(); testAppSettings().setValue("lang/language", "en"); @@ -162,6 +213,20 @@ void TutorWindowTest::mainSwitchLanguageToSpanishPersistsSelection() { QCOMPARE(difficultyTitle->text(), QString("Dificultad")); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-04 +// Summary: +// Verifies that the About dialog presents the application's main metadata. +// +// Situation: +// Main window open with the menu actions available. +// +// Action: +// The user opens the `About the app` action. +// +// Expected: +// The dialog shows author, license, and repository information. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainAboutDialogShowsMetadata() { clearTestAppSettings(); @@ -190,6 +255,22 @@ void TutorWindowTest::mainAboutDialogShowsMetadata() { QVERIFY(contentVerified); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-05 +// Summary: +// Checks that the LL(1) and SLR(1) quick references open and return to the +// main flow without errors. +// +// Situation: +// Main window open with the reference menu actions available. +// +// Action: +// The user opens the LL(1) reference, closes it, and repeats the process for +// the SLR(1) reference. +// +// Expected: +// Both dialogs appear with the correct title and can be closed normally. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainQuickReferencesOpen() { clearTestAppSettings(); @@ -228,6 +309,21 @@ void TutorWindowTest::mainQuickReferencesOpen() { QVERIFY(slrSeen); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-01, MAIN-TC-09 +// Summary: +// Verifies that the LL(1) and SLR(1) entry points on the home screen open the +// corresponding tutor windows. +// +// Situation: +// Main window in its initial state with navigation enabled. +// +// Action: +// The user presses `LL(1)` in one window and `SLR(1)` in another. +// +// Expected: +// The matching tutor window is created in each case. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainLlAndSlrEntryPointsOpenTutors() { clearTestAppSettings(); @@ -242,6 +338,22 @@ void TutorWindowTest::mainLlAndSlrEntryPointsOpenTutors() { QTRY_VERIFY(slrWindow.findChild() != nullptr); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-06 +// Summary: +// Runs a smoke test of the full tutorial, verifying that it opens both tutors +// and restores main navigation at the end. +// +// Situation: +// Main window open with the tutorial available. +// +// Action: +// The user presses `Tutorial` and advances through all steps with `Next`. +// +// Expected: +// The tutorial visits LL(1) and SLR(1), finishes without errors, and leaves +// the main controls enabled again. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainTutorialFlowCompletesAndReenablesControls() { clearTestAppSettings(); @@ -281,6 +393,24 @@ void TutorWindowTest::mainTutorialFlowCompletesAndReenablesControls() { QVERIFY(tutorialButton->isEnabled()); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-11 +// Summary: +// Checks that a completed session updates score, progress, and level, and +// that those values persist when the main window is recreated. +// +// Situation: +// Main window open with clean test settings and an LL(1) session launched from +// the UI. +// +// Action: +// The test simulates the tutor finishing with more correct answers than wrong +// ones. +// +// Expected: +// Score, progress bar, and level are updated, and the same values reappear +// when the main window is opened again. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainGamificationPersistsAcrossRestart() { clearTestAppSettings(); @@ -309,6 +439,21 @@ void TutorWindowTest::mainGamificationPersistsAcrossRestart() { QCOMPARE(reopened.findChild("labelScore")->text(), QString("Puntos: 2")); } +// ----------------------------------------------------------------------------- +// Case: MAIN-TC-12 +// Summary: +// Verifies that the main window loads persisted level, score, and language +// state correctly. +// +// Situation: +// Test settings preloaded manually before creating `MainWindow`. +// +// Action: +// The test opens the main window while reading the previously saved state. +// +// Expected: +// The visible indicators match the persisted values exactly. +// ----------------------------------------------------------------------------- void TutorWindowTest::mainStatePersistenceAcrossRestart() { clearTestAppSettings(); QSettings settings = testAppSettings(); diff --git a/tests/tutor/slr_tutor_window_test.cpp b/tests/tutor/slr_tutor_window_test.cpp index b29bdd96..8baebb4f 100644 --- a/tests/tutor/slr_tutor_window_test.cpp +++ b/tests/tutor/slr_tutor_window_test.cpp @@ -6,6 +6,7 @@ #include "tutor_grammar_fixtures.h" #include +#include #include #include #include @@ -15,8 +16,82 @@ #include #include +#include + namespace { +template void forEachSlrNoConflictFixture(Fn&& fn) { + for (const auto& fixture : TutorGrammarFixtures::slrNoConflictFixtures()) { + qInfo().noquote() << "SLR no-conflict fixture:" << fixture.name; + fn(fixture); + } +} + +template void forEachSlrConflictFixture(Fn&& fn) { + for (const auto& fixture : TutorGrammarFixtures::slrConflictFixtures()) { + qInfo().noquote() << "SLR conflict fixture:" << fixture.name; + fn(fixture); + } +} + +template void forEachSlrCbEpsilonFixture(Fn&& fn) { + for (const auto& fixture : TutorGrammarFixtures::slrFixturesWithCbEpsilon()) { + qInfo().noquote() << "SLR CB-epsilon fixture:" << fixture.name; + fn(fixture); + } +} + +template void forEachSlrCbNonEpsilonFixture(Fn&& fn) { + for (const auto& fixture : TutorGrammarFixtures::slrFixturesWithCbNonEpsilon()) { + qInfo().noquote() << "SLR CB-non-epsilon fixture:" << fixture.name; + fn(fixture); + } +} + +QString flexibleCommaAnswer(const QString& answer) { + QStringList parts = answer.split(',', Qt::SkipEmptyParts); + std::reverse(parts.begin(), parts.end()); + for (QString& part : parts) { + part = QString(" %1 ").arg(part.trimmed()); + } + return parts.join(" , "); +} + +QString flexibleIdCountAnswer(const QString& answer) { + QStringList parts = answer.split(',', Qt::SkipEmptyParts); + std::reverse(parts.begin(), parts.end()); + for (QString& part : parts) { + const QStringList kv = part.split(':', Qt::SkipEmptyParts); + if (kv.size() == 2) { + part = QString(" %1 : %2 ").arg(kv[0].trimmed(), kv[1].trimmed()); + } + } + return parts.join(" , "); +} + +QString flexibleMultilineArrowAnswer(const QString& answer) { + QStringList lines = answer.split('\n', Qt::SkipEmptyParts); + for (QString& line : lines) { + line = line.trimmed().replace("->", " -> "); + line.replace('.', ". "); + line = QString(" %1 ").arg(line); + } + return lines.join('\n'); +} + +QVector> flexibleSlrTable(const QVector>& raw) { + QVector> formatted = raw; + for (int row = 0; row < formatted.size(); ++row) { + for (int col = 0; col < formatted[row].size(); ++col) { + QString& cell = formatted[row][col]; + if (!cell.isEmpty()) { + cell = QString(" %1 ").arg(cell); + } + } + } + return formatted; +} + QPushButton* waitForTutorButton(SLRTutorWindow& tutor, const QString& objectName) { const int intervalMs = 20; int elapsed = 0; @@ -81,74 +156,136 @@ void driveSlrTutorToCbWithNonEpsilonSymbol(SLRTutorWindow& tutor) { } // namespace +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-01 +// Summary: +// Verifies that an SLR(1) session can be opened with a fixed grammar and +// without guided tutorial mode. +// +// Situation: +// SLR(1) tutor freshly created with no-conflict fixtures and `tm=nullptr`. +// +// Action: +// The test instantiates the tutor without further interaction. +// +// Expected: +// The tutor starts in state A and both counters begin at zero. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrCreatesTutorWithNullTutorialManager() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); + forEachSlrNoConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); - QCOMPARE(tutor.currentStateForTest(), QString("A")); - QCOMPARE(tutor.rightCountForTest(), 0); - QCOMPARE(tutor.wrongCountForTest(), 0); + QCOMPARE(tutor.currentStateForTest(), QString("A")); + QCOMPARE(tutor.rightCountForTest(), 0); + QCOMPARE(tutor.wrongCountForTest(), 0); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-03 +// Summary: +// Exercises the full error branch from state A until the tutor returns to B. +// +// Situation: +// SLR(1) tutor at startup using no-conflict SLR fixtures. +// +// Action: +// The user fails and then fixes the axiom, the symbol, the rules, the closure, +// and the regenerated initial state in sequence. +// +// Expected: +// The tutor walks through A1, A2, A3, A4 and A' before entering B. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateAErrorPathAdvancesThroughAprime() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); + forEachSlrNoConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); - tutor.setAnswerForTest("X -> .x"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A1")); + tutor.setAnswerForTest("X -> .x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A1")); - tutor.setAnswerForTest("X"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A1")); + tutor.setAnswerForTest("X"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A1")); - tutor.setAnswerForTest(tutor.solutionForA1()); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A2")); + tutor.setAnswerForTest(tutor.solutionForA1()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A2")); - tutor.setAnswerForTest("X"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A2")); + tutor.setAnswerForTest("X"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A2")); - tutor.setAnswerForTest(tutor.solutionForA2()); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A3")); + tutor.setAnswerForTest(tutor.solutionForA2()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A3")); - tutor.setAnswerForTest("X -> x"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A3")); + tutor.setAnswerForTest("X -> x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A3")); - tutor.setAnswerForTest(SlrTutorTestUtils::rulesAnswer(tutor.solutionForA3())); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A4")); + tutor.setAnswerForTest(SlrTutorTestUtils::rulesAnswer(tutor.solutionForA3())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A4")); - tutor.setAnswerForTest("X -> .x"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A4")); + tutor.setAnswerForTest("X -> .x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A4")); - tutor.setAnswerForTest(SlrTutorTestUtils::itemsAnswer(tutor.solutionForA4())); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("A'")); + tutor.setAnswerForTest(SlrTutorTestUtils::itemsAnswer(tutor.solutionForA4())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A'")); - tutor.setAnswerForTest("X -> .x"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("B")); + tutor.setAnswerForTest("X -> .x"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-02 +// Summary: +// Checks the direct happy path where the initial LR(0) state is answered +// correctly on the first try. +// +// Situation: +// SLR(1) tutor freshly opened with no-conflict fixtures. +// +// Action: +// The user enters I0 correctly. +// +// Expected: +// The tutor enters B and records the expected correct answer. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateACorrectPathAdvancesToB() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); - tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); - tutor.submitForTest(); + forEachSlrNoConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("B")); - QCOMPARE(tutor.rightCountForTest(), 1); - QCOMPARE(tutor.wrongCountForTest(), 0); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + QCOMPARE(tutor.rightCountForTest(), 1); + QCOMPARE(tutor.wrongCountForTest(), 0); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-04 +// Summary: +// Validates the current behavior where B and C still advance even if the user +// answers incorrectly. +// +// Situation: +// SLR(1) tutor already in B after building the initial state correctly. +// +// Action: +// The user answers incorrectly for both the number of generated states and the +// number of items in the inspected state. +// +// Expected: +// The tutor still advances from B to C and from C to CA, while incrementing +// the wrong counter. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateBAndCIncorrectAnswersStillAdvance() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -167,6 +304,22 @@ void TutorWindowTest::slrStateBAndCIncorrectAnswersStillAdvance() { QCOMPARE(tutor.wrongCountForTest(), 2); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-05 +// Summary: +// Checks that CA repeats the same question while the set of symbols after the +// dot is still wrong. +// +// Situation: +// SLR(1) tutor in CA after entering the analysis of an LR(0) state. +// +// Action: +// The user fails once and then corrects the requested set. +// +// Expected: +// The tutor stays in CA until the answer is correct, then moves to CB or back +// to B as appropriate. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateCAWrongThenCorrect() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -192,49 +345,100 @@ void TutorWindowTest::slrStateCAWrongThenCorrect() { QCOMPARE(tutor.currentStateForTest(), nextState); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-06 +// Summary: +// Validates the CB branch where the analyzed symbol is `EPSILON` and the only +// valid answer is an empty input. +// +// Situation: +// SLR(1) tutor in CB on a transition whose expected result is empty. +// +// Action: +// The user first types a non-empty answer; then, in a second session, leaves +// the response empty. +// +// Expected: +// The non-empty answer counts as wrong and the empty answer is accepted. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateCBEpsilonBranchAcceptsOnlyEmpty() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); - driveSlrTutorUntilCbSymbol(tutor, "EPSILON"); + forEachSlrCbEpsilonFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; - tutor.setAnswerForTest("x"); - tutor.submitForTest(); - QCOMPARE(tutor.wrongCountForTest(), 1); - QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorUntilCbSymbol(tutor, "EPSILON"); - SLRTutorWindow tutor2(grammar, nullptr); - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor2); - driveSlrTutorUntilCbSymbol(tutor2, "EPSILON"); - const int rightBefore = tutor2.rightCountForTest(); - const int wrongBefore = tutor2.wrongCountForTest(); - tutor2.setAnswerForTest(QString()); - tutor2.submitForTest(); - QCOMPARE(tutor2.rightCountForTest(), rightBefore + 1); - QCOMPARE(tutor2.wrongCountForTest(), wrongBefore); + tutor.setAnswerForTest("x"); + tutor.submitForTest(); + QCOMPARE(tutor.wrongCountForTest(), 1); + QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + + SLRTutorWindow tutor2(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor2); + driveSlrTutorUntilCbSymbol(tutor2, "EPSILON"); + const int rightBefore = tutor2.rightCountForTest(); + const int wrongBefore = tutor2.wrongCountForTest(); + tutor2.setAnswerForTest(QString()); + tutor2.submitForTest(); + QCOMPARE(tutor2.rightCountForTest(), rightBefore + 1); + QCOMPARE(tutor2.wrongCountForTest(), wrongBefore); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-07 +// Summary: +// Checks the CB branch for a real symbol, where the user must provide the item +// set of the destination state. +// +// Situation: +// SLR(1) tutor in CB on a non-empty transition. +// +// Action: +// One session answers incorrectly; another answers correctly. +// +// Expected: +// The flow advances in both cases according to the real behavior, but only the +// correct answer counts as a success. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateCBNonEpsilonBranchAdvancesOnWrongAndCorrect() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + forEachSlrCbNonEpsilonFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; - SLRTutorWindow tutor(grammar, nullptr); - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); - driveSlrTutorToCbWithNonEpsilonSymbol(tutor); - - tutor.setAnswerForTest("1"); - tutor.submitForTest(); - QCOMPARE(tutor.wrongCountForTest(), 1); - QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToCbWithNonEpsilonSymbol(tutor); - SLRTutorWindow tutor2(grammar, nullptr); - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor2); - driveSlrTutorToCbWithNonEpsilonSymbol(tutor2); - tutor2.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor2)); - tutor2.submitForTest(); - QVERIFY(tutor2.currentStateForTest() == "CB" || tutor2.currentStateForTest() == "B"); + tutor.setAnswerForTest("1"); + tutor.submitForTest(); + QCOMPARE(tutor.wrongCountForTest(), 1); + QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + + SLRTutorWindow tutor2(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor2); + driveSlrTutorToCbWithNonEpsilonSymbol(tutor2); + tutor2.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor2)); + tutor2.submitForTest(); + QVERIFY(tutor2.currentStateForTest() == "CB" || tutor2.currentStateForTest() == "B"); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-08 +// Summary: +// Automatically traverses the B -> C -> CA -> CB loop until there are no more +// pending states and the tutor reaches D. +// +// Situation: +// SLR(1) tutor after resolving A correctly. +// +// Action: +// The test keeps answering the construction loop steps correctly. +// +// Expected: +// The tutor finishes the LR(0) collection and enters D. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrDriveThroughCollectionUntilD() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -244,6 +448,21 @@ void TutorWindowTest::slrDriveThroughCollectionUntilD() { QCOMPARE(tutor.currentStateForTest(), QString("D")); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-10 +// Summary: +// Exercises the error path of block D until the tutor enters E. +// +// Situation: +// SLR(1) tutor already in D, ready to ask about table size and symbol count. +// +// Action: +// The user fails the table size, fails and fixes the row count, fails and +// fixes the column count, and then fails the final size prompt again. +// +// Expected: +// The tutor moves through D1, D2 and D' and enters E at the end. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateDErrorPathAdvancesToE() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -276,6 +495,22 @@ void TutorWindowTest::slrStateDErrorPathAdvancesToE() { QCOMPARE(tutor.currentStateForTest(), QString("E")); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-12 +// Summary: +// Checks the error path for E, E1 and E2 before moving into conflict +// analysis. +// +// Situation: +// SLR(1) tutor in E after completing block D. +// +// Action: +// The user fails the number of states with completed items, fails and fixes +// the ID list, and fails the `id:n` count list. +// +// Expected: +// The tutor walks through E1 and E2 and eventually enters F. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateEErrorPathAdvancesToF() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -300,46 +535,95 @@ void TutorWindowTest::slrStateEErrorPathAdvancesToF() { QCOMPARE(tutor.currentStateForTest(), QString("F")); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-16 +// Summary: +// Validates the no-conflict LR(0) case, where F must accept an empty response +// and jump directly to G. +// +// Situation: +// SLR(1) tutor in F using no-conflict fixtures. +// +// Action: +// The user leaves the response empty when asked about conflict states. +// +// Expected: +// The tutor accepts the response and enters G. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateFNoConflictAdvancesToG() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); - driveSlrTutorToState(tutor, "F"); - QCOMPARE(tutor.solutionForF().isEmpty(), true); + forEachSlrNoConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "F"); + QCOMPARE(tutor.solutionForF().isEmpty(), true); - tutor.setAnswerForTest(QString()); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("G")); + tutor.setAnswerForTest(QString()); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("G")); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-13, SLR1-TC-14, SLR1-TC-15 +// Summary: +// Checks LR(0) conflict detection, retry behavior in F, and per-conflict +// resolution in FA. +// +// Situation: +// SLR(1) tutor in F using a fixture with an LR(0) conflict. +// +// Action: +// The user first fails the conflict-state list and then resolves each conflict +// state by correcting the answer when needed. +// +// Expected: +// The tutor stays in F or FA while answers are wrong and moves to G once all +// conflicts have been resolved. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateFConflictBranchAdvancesToFAAndThenG() { - const Grammar grammar = TutorGrammarFixtures::makeSlrConflictGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); - driveSlrTutorToState(tutor, "F"); - QVERIFY(!tutor.solutionForF().isEmpty()); - - tutor.setAnswerForTest("999"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("F")); + forEachSlrConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "F"); + QVERIFY(!tutor.solutionForF().isEmpty()); - tutor.setAnswerForTest(SlrTutorTestUtils::idSetAnswer(tutor.solutionForF())); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("FA")); + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("F")); - tutor.setAnswerForTest("x"); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("FA")); + tutor.setAnswerForTest(SlrTutorTestUtils::idSetAnswer(tutor.solutionForF())); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("FA")); - while (tutor.currentStateForTest() == "FA") { - tutor.setAnswerForTest(SlrTutorTestUtils::stringSetAnswer(tutor.solutionForFA())); + tutor.setAnswerForTest("x"); tutor.submitForTest(); - } - QCOMPARE(tutor.currentStateForTest(), QString("G")); + QCOMPARE(tutor.currentStateForTest(), QString("FA")); + + while (tutor.currentStateForTest() == "FA") { + tutor.setAnswerForTest(SlrTutorTestUtils::stringSetAnswer(tutor.solutionForFA())); + tutor.submitForTest(); + } + QCOMPARE(tutor.currentStateForTest(), QString("G")); + }); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-17 +// Summary: +// Verifies that block G repeats the question after a wrong answer and reaches +// H once reductions are answered correctly. +// +// Situation: +// SLR(1) tutor in G with at least one reducible state still pending. +// +// Action: +// The user fails once and then the test completes the remainder of the block +// correctly. +// +// Expected: +// The tutor stays in G after the failure and eventually opens H after all +// reductions are completed. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateGWrongThenCorrectReachesH() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -354,6 +638,22 @@ void TutorWindowTest::slrStateGWrongThenCorrectReachesH() { driveSlrTutorToH(tutor); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-18 +// Summary: +// Checks that an incorrect SLR table does not close the dialog and that the +// problematic cell is highlighted. +// +// Situation: +// SLR(1) tutor in H with the table dialog open. +// +// Action: +// The user submits a table with semantically incorrect content. +// +// Expected: +// Feedback is shown, the dialog stays open, and incorrect cells are marked in +// red. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrStateHIncorrectTableKeepsDialogOpen() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -380,6 +680,21 @@ void TutorWindowTest::slrStateHIncorrectTableKeepsDialogOpen() { QVERIFY(dialog->isVisible()); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-18 +// Summary: +// Validates guided mode for the SLR table and the return to the main table +// dialog once it is completed. +// +// Situation: +// SLR(1) tutor in H with the table dialog visible. +// +// Action: +// The user presses `Modo guiado` and completes the wizard step by step. +// +// Expected: +// The wizard closes correctly and the table dialog remains available in H. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrGuidedModeWizardCompletesAndReturnsToTable() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -404,6 +719,23 @@ void TutorWindowTest::slrGuidedModeWizardCompletesAndReturnsToTable() { QCOMPARE(tutor.currentStateForTest(), QString("H")); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-20 +// Summary: +// Verifies cancellation of the SLR table dialog when the user chooses either +// to continue or to leave the tutor. +// +// Situation: +// SLR(1) tutor in H with the table dialog open. +// +// Action: +// The user closes the dialog, first answers `No` and then `Yes` in the +// confirmation dialog. +// +// Expected: +// The dialog reopens when continuing and the tutor requests exit when the user +// confirms. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrTableDialogCancelNoReopensAndYesRequestsExit() { const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); @@ -429,55 +761,183 @@ void TutorWindowTest::slrTableDialogCancelNoReopensAndYesRequestsExit() { QCOMPARE(exitSpy.takeFirst().at(0).toBool(), false); } +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-19 +// Summary: +// Checks the correct final SLR tutor flow, including PDF export and a clean +// exit path. +// +// Situation: +// SLR(1) tutor in H with no-conflict fixtures and the table ready to be +// completed. +// +// Action: +// The user fills the correct table, exports the PDF, and then presses `Salir`. +// +// Expected: +// The tutor enters `fin`, generates the PDF, and emits the exit request with +// session results. +// ----------------------------------------------------------------------------- void TutorWindowTest::slrFinalTableCorrectPathExportsAndExits() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + forEachSlrNoConflictFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + SLRTutorWindow tutor(grammar, nullptr); + QSignalSpy exitSpy(&tutor, &SLRTutorWindow::exitRequested); - SLRTutorWindow tutor(grammar, nullptr); - QSignalSpy exitSpy(&tutor, &SLRTutorWindow::exitRequested); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToH(tutor); + + SLRTableDialog* dialog = waitForSlrTableDialog(); + auto* table = dialog->findChild("slrTableWidget"); + QVERIFY(table != nullptr); + + QtModalTestUtils::submitSlrTableDialog( + dialog, SlrTutorTestUtils::buildExpectedTable(grammar, table)); + + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString pdfPath = tempDir.filePath( + QString("%1-export.pdf").arg(fixture.name)); + + tutor.setNextExportFilePathForTest(pdfPath); + auto* exportButton = waitForTutorButton(tutor, "slrTutorExportPdfButton"); + QVERIFY(exportButton != nullptr); + QTest::mouseClick(exportButton, Qt::LeftButton); + QTRY_VERIFY(QFileInfo::exists(pdfPath)); + QVERIFY(QFileInfo(pdfPath).size() > 0); + + auto* exitButton = waitForTutorButton(tutor, "slrTutorExitButton"); + QVERIFY(exitButton != nullptr); + QTest::mouseClick(exitButton, Qt::LeftButton); + QTRY_COMPARE(exitSpy.count(), 1); + QCOMPARE(exitSpy.takeFirst().at(0).toBool(), true); + }); +} - SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); - driveSlrTutorToH(tutor); +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Minimal visual smoke test for the SLR(1) window to ensure it appears with +// key widgets and accepts a basic answer. +// +// Situation: +// SLR(1) tutor open with no-conflict fixtures. +// +// Action: +// The test shows the window, locates essential widgets, and resolves one +// initial correct answer. +// +// Expected: +// The UI appears without crashing and the flow advances from A to B. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrSmokeGuiFindsCoreWidgets() { + forEachSlrNoConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); + tutor.show(); + QCoreApplication::processEvents(); - SLRTableDialog* dialog = waitForSlrTableDialog(); - auto* table = dialog->findChild("slrTableWidget"); - QVERIFY(table != nullptr); + QVERIFY(tutor.findChild("listWidget") != nullptr); + QVERIFY(tutor.findChild("userResponse") != nullptr); + QVERIFY(tutor.findChild("confirmButton") != nullptr); + QVERIFY(tutor.findChild("backButton") != nullptr); + + tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + }); +} + +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Ensures that the SLR tutor accepts common formatting variants in otherwise +// correct user answers. +// +// Situation: +// SLR(1) tutor using both no-conflict and conflict fixtures for item, set, ID, +// and `id:n` style questions. +// +// Action: +// The user answers with extra spaces, spaced arrows, and typing variations +// around commas and colons. +// +// Expected: +// Correct answers are still accepted and the flow progresses like a normal +// session. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAcceptsFlexibleUserFormatting() { + forEachSlrNoConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); + + tutor.setAnswerForTest( + flexibleMultilineArrowAnswer(SlrTutorTestUtils::correctAnswerForCurrentState(tutor))); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("B")); - QtModalTestUtils::submitSlrTableDialog( - dialog, SlrTutorTestUtils::buildExpectedTable(grammar, table)); + tutor.setAnswerForTest(QString(" %1 ").arg(SlrTutorTestUtils::correctAnswerForCurrentState(tutor))); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("C")); - QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + tutor.setAnswerForTest(QString(" %1 ").arg(SlrTutorTestUtils::correctAnswerForCurrentState(tutor))); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("CA")); - QTemporaryDir tempDir; - QVERIFY(tempDir.isValid()); - const QString pdfPath = tempDir.filePath("slr_tutor_test.pdf"); + tutor.setAnswerForTest(flexibleCommaAnswer( + SlrTutorTestUtils::correctAnswerForCurrentState(tutor))); + tutor.submitForTest(); + QVERIFY(tutor.currentStateForTest() == "CB" || tutor.currentStateForTest() == "B"); + }); - tutor.setNextExportFilePathForTest(pdfPath); - auto* exportButton = waitForTutorButton(tutor, "slrTutorExportPdfButton"); - QVERIFY(exportButton != nullptr); - QTest::mouseClick(exportButton, Qt::LeftButton); - QTRY_VERIFY(QFileInfo::exists(pdfPath)); - QVERIFY(QFileInfo(pdfPath).size() > 0); + forEachSlrConflictFixture([](const auto& fixture) { + SLRTutorWindow tutor(fixture.grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToState(tutor, "E"); + tutor.setAnswerForTest("999"); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("E1")); + tutor.setAnswerForTest(flexibleCommaAnswer( + SlrTutorTestUtils::idSetAnswer(tutor.solutionForE1()))); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("E2")); - auto* exitButton = waitForTutorButton(tutor, "slrTutorExitButton"); - QVERIFY(exitButton != nullptr); - QTest::mouseClick(exitButton, Qt::LeftButton); - QTRY_COMPARE(exitSpy.count(), 1); - QCOMPARE(exitSpy.takeFirst().at(0).toBool(), true); + tutor.setAnswerForTest(flexibleIdCountAnswer( + SlrTutorTestUtils::idCountAnswer(tutor.solutionForE2()))); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("F")); + }); } -void TutorWindowTest::slrSmokeGuiFindsCoreWidgets() { - const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); - - SLRTutorWindow tutor(grammar, nullptr); - tutor.show(); - QCoreApplication::processEvents(); +// ----------------------------------------------------------------------------- +// Case: Internal +// Summary: +// Checks that the SLR table accepts correct entries even when cells contain +// additional spaces. +// +// Situation: +// SLR(1) tutor in H with the table dialog open and no-conflict fixtures. +// +// Action: +// The user fills the correct table with extra padding inside the cells. +// +// Expected: +// The table validates successfully and the tutor reaches the final state. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAcceptsFlexibleTableCellFormatting() { + forEachSlrNoConflictFixture([](const auto& fixture) { + const Grammar grammar = fixture.grammar; + SLRTutorWindow tutor(grammar, nullptr); + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + driveSlrTutorToH(tutor); - QVERIFY(tutor.findChild("listWidget") != nullptr); - QVERIFY(tutor.findChild("userResponse") != nullptr); - QVERIFY(tutor.findChild("confirmButton") != nullptr); - QVERIFY(tutor.findChild("backButton") != nullptr); + SLRTableDialog* dialog = waitForSlrTableDialog(); + auto* table = dialog->findChild("slrTableWidget"); + QVERIFY(table != nullptr); - tutor.setAnswerForTest(SlrTutorTestUtils::correctAnswerForCurrentState(tutor)); - tutor.submitForTest(); - QCOMPARE(tutor.currentStateForTest(), QString("B")); + const QVector> expected = + SlrTutorTestUtils::buildExpectedTable(grammar, table); + QtModalTestUtils::submitSlrTableDialog(dialog, flexibleSlrTable(expected)); + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + }); } diff --git a/tests/tutor/tutor_window_test.h b/tests/tutor/tutor_window_test.h index e5494fd1..560deb63 100644 --- a/tests/tutor/tutor_window_test.h +++ b/tests/tutor/tutor_window_test.h @@ -20,6 +20,8 @@ class TutorWindowTest : public QObject { void exitButtonFinishesWithoutExport(); void exportsConversationToPdf(); void smokeGuiFindsCoreWidgets(); + void llAcceptsFlexibleUserFormatting(); + void llAcceptsFlexibleTableCellFormatting(); void slrCreatesTutorWithNullTutorialManager(); void slrStateAErrorPathAdvancesThroughAprime(); @@ -39,6 +41,8 @@ class TutorWindowTest : public QObject { void slrTableDialogCancelNoReopensAndYesRequestsExit(); void slrFinalTableCorrectPathExportsAndExits(); void slrSmokeGuiFindsCoreWidgets(); + void slrAcceptsFlexibleUserFormatting(); + void slrAcceptsFlexibleTableCellFormatting(); void mainInitialUiIsVisibleAndEnabled(); void mainSwitchLanguageToEnglishPersistsSelection(); From 98bfd0f98a7ba30a44680d01646a70dd78a5c739 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Sat, 30 May 2026 17:37:05 +0200 Subject: [PATCH 12/12] test: Add TRACEABILITY helper document --- tests/TRACEABILITY.md | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/TRACEABILITY.md diff --git a/tests/TRACEABILITY.md b/tests/TRACEABILITY.md new file mode 100644 index 00000000..fc98e746 --- /dev/null +++ b/tests/TRACEABILITY.md @@ -0,0 +1,81 @@ +# Traceability Matrix + +This document maps each manual functional test case under `qa/test-cases/` to the automated Qt Test coverage in `tests/tutor/`. + +## Fixture Groups + +### LL(1) + +- `llFixtures()` + - `ll-simple` + - `ll-epsilon` + - `ll-branching` + +### SLR(1) + +- `slrNoConflictFixtures()` + - `slr-simple` + - `slr-chain` +- `slrConflictFixtures()` + - `slr-conflict` +- `slrFixturesWithCbEpsilon()` +- `slrFixturesWithCbNonEpsilon()` + +## Main App + +| Manual Case | Automated Test | File | Fixture Scope | Notes | +|---|---|---|---|---| +| `MAIN-TC-01` | `mainInitialUiIsVisibleAndEnabled` | `main_window_test.cpp` | N/A | Initial UI visibility and enabled controls | +| `MAIN-TC-02` | `mainSwitchLanguageToEnglishPersistsSelection` | `main_window_test.cpp` | N/A | Uses test settings and avoids real restart under `SYNTAXTUTOR_TESTING` | +| `MAIN-TC-03` | `mainSwitchLanguageToSpanishPersistsSelection` | `main_window_test.cpp` | N/A | Uses test settings and avoids real restart under `SYNTAXTUTOR_TESTING` | +| `MAIN-TC-04` | `mainAboutDialogShowsMetadata` | `main_window_test.cpp` | N/A | About dialog smoke/content check | +| `MAIN-TC-05` | `mainQuickReferencesOpen` | `main_window_test.cpp` | N/A | LL and SLR quick references | +| `MAIN-TC-06` | `mainTutorialFlowCompletesAndReenablesControls` | `main_window_test.cpp` | N/A | Full tutorial smoke path | +| `MAIN-TC-07` | `llAcceptsFlexibleUserFormatting` | `ll_tutor_window_test.cpp` | `llFixtures()` | Covered in tutor suite rather than main smoke suite | +| `MAIN-TC-08` | `tableDialogCancelNoReopensAndYesRequestsExit`, `tableDialogCancelInCPrimeNoReopensAndYesRequestsExit` | `ll_tutor_window_test.cpp` | specific LL fixture | Covered in tutor suite | +| `MAIN-TC-09` | `mainLlAndSlrEntryPointsOpenTutors`, `slrStateHIncorrectTableKeepsDialogOpen` | `main_window_test.cpp`, `slr_tutor_window_test.cpp` | mixed | Main covers entry point; tutor suite covers table processing | +| `MAIN-TC-10` | `exportButtonExportsPdf`, `slrFinalTableCorrectPathExportsAndExits` | `ll_tutor_window_test.cpp`, `slr_tutor_window_test.cpp` | LL/SLR fixture groups | Export flow covered in tutor suites | +| `MAIN-TC-11` | `mainGamificationPersistsAcrossRestart` | `main_window_test.cpp` | N/A | Simulates finished tutor session | +| `MAIN-TC-12` | `mainStatePersistenceAcrossRestart` | `main_window_test.cpp` | N/A | Reads persisted test settings | + +## LL(1) + +| Manual Case | Automated Test | File | Fixture Scope | Notes | +|---|---|---|---|---| +| `LL1-TC-01` | `createsTutorWithNullTutorialManager` | `ll_tutor_window_test.cpp` | `llFixtures()` | Session creation and initial state | +| `LL1-TC-02` | `stateAErrorPathAdvancesThroughAStates` | `ll_tutor_window_test.cpp` | `llFixtures()` | Full A -> A1 -> A2 -> A' path | +| `LL1-TC-03` | `stateACorrectPathAdvancesToB` | `ll_tutor_window_test.cpp` | `llFixtures()` | Direct A -> B path | +| `LL1-TC-04` | `stateBDirectAndFallbackPathsUpdateCounters`, `stateBWrongAnswersStayInB1AndB2` | `ll_tutor_window_test.cpp` | specific LL fixture | Non-axiom B branch coverage | +| `LL1-TC-05` | `stateBAxiomBranchSkipsB2` | `ll_tutor_window_test.cpp` | specific LL fixture | Axiom branch skips B2 | +| `LL1-TC-06` | `stateBDirectAndFallbackPathsUpdateCounters` | `ll_tutor_window_test.cpp` | specific LL fixture | Direct correct B answer | +| `LL1-TC-07` | `stateCCorrectPathOpensExportActions`, `exportButtonExportsPdf`, `exitButtonFinishesWithoutExport` | `ll_tutor_window_test.cpp` | mixed | Final state actions and export | +| `LL1-TC-08` | `stateCWrongAttemptsReachCPrimeAndRecover` | `ll_tutor_window_test.cpp` | specific LL fixture | Retry limit and C' recovery | +| `LL1-TC-09` | `tableDialogCancelNoReopensAndYesRequestsExit`, `tableDialogCancelInCPrimeNoReopensAndYesRequestsExit` | `ll_tutor_window_test.cpp` | specific LL fixture | Cancel flow in C and C' | +| `Interno` | `exportsConversationToPdf` | `ll_tutor_window_test.cpp` | `ll-epsilon` | Direct export helper validation | +| `Interno` | `smokeGuiFindsCoreWidgets` | `ll_tutor_window_test.cpp` | `llFixtures()` | Minimal GUI smoke | +| `Interno` | `llAcceptsFlexibleUserFormatting` | `ll_tutor_window_test.cpp` | `llFixtures()` | Input formatting tolerance | +| `Interno` | `llAcceptsFlexibleTableCellFormatting` | `ll_tutor_window_test.cpp` | `llFixtures()` | Table cell spacing tolerance | + +## SLR(1) + +| Manual Case | Automated Test | File | Fixture Scope | Notes | +|---|---|---|---|---| +| `SLR1-TC-01` | `slrCreatesTutorWithNullTutorialManager` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | Session creation and initial state | +| `SLR1-TC-02` | `slrStateACorrectPathAdvancesToB` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | Direct A -> B path | +| `SLR1-TC-03` | `slrStateAErrorPathAdvancesThroughAprime` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | Full A error branch | +| `SLR1-TC-04` | `slrStateBAndCIncorrectAnswersStillAdvance` | `slr_tutor_window_test.cpp` | specific SLR fixture | Matches current tutor behavior | +| `SLR1-TC-05` | `slrStateCAWrongThenCorrect` | `slr_tutor_window_test.cpp` | specific SLR fixture | CA retry behavior | +| `SLR1-TC-06` | `slrStateCBEpsilonBranchAcceptsOnlyEmpty` | `slr_tutor_window_test.cpp` | `slrFixturesWithCbEpsilon()` | CB empty branch | +| `SLR1-TC-07` | `slrStateCBNonEpsilonBranchAdvancesOnWrongAndCorrect` | `slr_tutor_window_test.cpp` | `slrFixturesWithCbNonEpsilon()` | CB non-empty branch | +| `SLR1-TC-08` | `slrDriveThroughCollectionUntilD` | `slr_tutor_window_test.cpp` | specific SLR fixture | Collection loop until D | +| `SLR1-TC-10` | `slrStateDErrorPathAdvancesToE` | `slr_tutor_window_test.cpp` | specific SLR fixture | D error path | +| `SLR1-TC-12` | `slrStateEErrorPathAdvancesToF` | `slr_tutor_window_test.cpp` | specific SLR fixture | E error path | +| `SLR1-TC-13`, `SLR1-TC-14`, `SLR1-TC-15` | `slrStateFConflictBranchAdvancesToFAAndThenG` | `slr_tutor_window_test.cpp` | `slrConflictFixtures()` | Conflict detection and FA resolution | +| `SLR1-TC-16` | `slrStateFNoConflictAdvancesToG` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | No-conflict F branch | +| `SLR1-TC-17` | `slrStateGWrongThenCorrectReachesH` | `slr_tutor_window_test.cpp` | specific SLR fixture | G retry and completion | +| `SLR1-TC-18` | `slrStateHIncorrectTableKeepsDialogOpen`, `slrGuidedModeWizardCompletesAndReturnsToTable` | `slr_tutor_window_test.cpp` | specific SLR fixture | Adapted to current UI: guided mode via button | +| `SLR1-TC-19` | `slrFinalTableCorrectPathExportsAndExits` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | Final table, PDF export, exit | +| `SLR1-TC-20` | `slrTableDialogCancelNoReopensAndYesRequestsExit` | `slr_tutor_window_test.cpp` | specific SLR fixture | Table dialog cancel flow | +| `Interno` | `slrSmokeGuiFindsCoreWidgets` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | Minimal GUI smoke | +| `Interno` | `slrAcceptsFlexibleUserFormatting` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()`, `slrConflictFixtures()` | Input formatting tolerance | +| `Interno` | `slrAcceptsFlexibleTableCellFormatting` | `slr_tutor_window_test.cpp` | `slrNoConflictFixtures()` | Table cell spacing tolerance |