From c31face8dd735df70fb0de67da965328be31673d Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:36:16 +0200 Subject: [PATCH 1/3] feat: add ll guided mode --- SyntaxTutor.pro | 2 + src/gui/llwizard.h | 240 +++++++++++++++++++++++++++++++++++++++++ src/gui/llwizardpage.h | 156 +++++++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 src/gui/llwizard.h create mode 100644 src/gui/llwizardpage.h diff --git a/SyntaxTutor.pro b/SyntaxTutor.pro index 4e25def..96e50ac 100644 --- a/SyntaxTutor.pro +++ b/SyntaxTutor.pro @@ -61,6 +61,8 @@ HEADERS += \ src/gui/grammareditordialog.h \ src/gui/lltabledialog.h \ src/gui/lltutorwindow.h \ + src/gui/llwizard.h \ + src/gui/llwizardpage.h \ src/gui/mainwindow.h \ src/gui/slrtabledialog.h \ src/gui/slrtutorwindow.h \ diff --git a/src/gui/llwizard.h b/src/gui/llwizard.h new file mode 100644 index 0000000..8f89809 --- /dev/null +++ b/src/gui/llwizard.h @@ -0,0 +1,240 @@ +/* + * SyntaxTutor - Interactive Tutorial About Syntax Analyzers + * Copyright (C) 2025 Jose R. (jose-rzm) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LLWIZARD_H +#define LLWIZARD_H + +#include "ll1_parser.hpp" +#include "llwizardpage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @class LLWizard + * @brief Interactive assistant that guides the student step-by-step through + * the LL(1) parsing table. + * + * This dialog presents one non-empty cell of the LL(1) table at a time, + * asking the user to deduce the production that belongs in it from the + * prediction symbols (símbolos directores): the cell (A, b) holds the + * production A → α with b ∈ SD(A → α). It mirrors the SLR(1) guided dialog + * and intentionally avoids the native wizard widget to keep a consistent + * look across platforms. + */ +class LLWizard : public QDialog { + Q_OBJECT + public: + /** + * @brief Constructs the LL(1) guided dialog from the computed table. + * + * @param parser The LL(1) parser instance holding the reference table. + * @param rowHeaders Non-terminal symbols in display order. + * @param colHeaders Terminal symbols in display order. + * @param parent Parent widget. + */ + LLWizard(LL1Parser& parser, const QStringList& rowHeaders, + const QStringList& colHeaders, QWidget* parent = nullptr) + : QDialog(parent) { + setProperty("guidedDialog", "ll"); + setWindowTitle(tr("Completar tabla LL(1)")); + setWindowFlag(Qt::WindowCloseButtonHint, true); + setModal(true); + resize(640, 440); + setMinimumSize(560, 400); + + auto* rootLayout = new QVBoxLayout(this); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(0); + + auto* panel = new QFrame(this); + panel->setObjectName("llWizardPanel"); + + auto* panelLayout = new QVBoxLayout(panel); + panelLayout->setContentsMargins(28, 24, 28, 24); + panelLayout->setSpacing(20); + + auto* headerLayout = new QHBoxLayout(); + headerLayout->setSpacing(12); + + auto* titleColumn = new QVBoxLayout(); + titleColumn->setContentsMargins(0, 0, 0, 0); + titleColumn->setSpacing(6); + + m_titleLabel = new QLabel(panel); + m_titleLabel->setObjectName("llWizardTitle"); + titleColumn->addWidget(m_titleLabel); + + m_stepCounterLabel = new QLabel(panel); + m_stepCounterLabel->setObjectName("llWizardStepCounter"); + titleColumn->addWidget(m_stepCounterLabel); + + m_progressBar = new QProgressBar(panel); + m_progressBar->setObjectName("llWizardProgress"); + m_progressBar->setTextVisible(false); + m_progressBar->setRange(0, 100); + m_progressBar->setFixedHeight(4); + titleColumn->addWidget(m_progressBar); + + headerLayout->addLayout(titleColumn, 1); + + m_closeButton = new QPushButton(tr("Salir"), panel); + m_closeButton->setObjectName("llWizardCloseButton"); + m_closeButton->setAutoDefault(false); + m_closeButton->setDefault(false); + m_closeButton->setCursor(Qt::PointingHandCursor); + headerLayout->addWidget(m_closeButton, 0, Qt::AlignTop); + + panelLayout->addLayout(headerLayout); + + m_stack = new QStackedWidget(panel); + m_stack->setObjectName("llWizardStack"); + panelLayout->addWidget(m_stack, 1); + + auto* footerLayout = new QHBoxLayout(); + footerLayout->setContentsMargins(0, 0, 0, 0); + footerLayout->setSpacing(12); + footerLayout->addStretch(1); + + m_nextButton = new QPushButton(panel); + m_nextButton->setObjectName("llWizardNextButton"); + m_nextButton->setProperty("role", "primary"); + m_nextButton->setAutoDefault(false); + m_nextButton->setDefault(false); + footerLayout->addWidget(m_nextButton); + panelLayout->addLayout(footerLayout); + + rootLayout->addWidget(panel); + + for (const QString& nonTerminal : rowHeaders) { + const auto rowIt = parser.ll1_t_.find(nonTerminal.toStdString()); + if (rowIt == parser.ll1_t_.end()) { + continue; + } + for (const QString& symbol : colHeaders) { + const auto cellIt = rowIt->second.find(symbol.toStdString()); + if (cellIt == rowIt->second.end() || cellIt->second.empty() || + cellIt->second[0].empty()) { + continue; + } + + QStringList production; + for (const std::string& s : cellIt->second[0]) { + production.append(QString::fromStdString(s)); + } + const QString expected = production.join(' '); + + const QString explanation = + tr("La celda (%1, '%2') no está vacía: existe una " + "producción %1 → α tal que '%2' ∈ SD(%1 → α).\n" + "¿Qué producción α escribes en esta celda?") + .arg(nonTerminal, symbol); + + auto* page = new LLWizardPage(nonTerminal, symbol, explanation, + expected, m_stack); + connect(page, &LLWizardPage::completionChanged, this, + &LLWizard::updateCurrentPage); + connect(page, &LLWizardPage::submitRequested, this, + &LLWizard::advance); + m_stack->addWidget(page); + } + } + + connect(m_closeButton, &QPushButton::clicked, this, &QDialog::reject); + connect(m_nextButton, &QPushButton::clicked, this, &LLWizard::advance); + + updateCurrentPage(); + QTimer::singleShot(0, this, [this]() { + if (auto* page = currentPage()) { + page->focusAnswerField(); + } + }); + } + + LLWizardPage* currentPage() const { + return qobject_cast(m_stack->currentWidget()); + } + + QPushButton* nextButton() const { return m_nextButton; } + QPushButton* closeButton() const { return m_closeButton; } + + bool isOnLastPage() const { + return m_stack->count() > 0 && + m_stack->currentIndex() == m_stack->count() - 1; + } + + private slots: + void advance() { + auto* page = currentPage(); + if (page == nullptr || !page->isComplete()) { + return; + } + + if (isOnLastPage()) { + accept(); + return; + } + + m_stack->setCurrentIndex(m_stack->currentIndex() + 1); + updateCurrentPage(); + if (auto* nextPage = currentPage()) { + nextPage->focusAnswerField(); + } + } + + void updateCurrentPage() { + auto* page = currentPage(); + if (page == nullptr) { + m_titleLabel->setText(tr("Modo guiado")); + m_stepCounterLabel->clear(); + m_progressBar->setValue(0); + m_nextButton->setEnabled(false); + m_nextButton->setText(tr("Finalizar")); + return; + } + + const int currentStep = m_stack->currentIndex() + 1; + const int totalSteps = m_stack->count(); + m_titleLabel->setText(page->titleText()); + m_stepCounterLabel->setText( + tr("Paso %1 de %2").arg(currentStep).arg(totalSteps)); + m_progressBar->setValue((100 * currentStep) / qMax(1, totalSteps)); + m_nextButton->setEnabled(page->isComplete()); + m_nextButton->setCursor(page->isComplete() ? Qt::PointingHandCursor + : Qt::ArrowCursor); + m_nextButton->setText(isOnLastPage() ? tr("Finalizar") + : tr("Continuar")); + } + + private: + QLabel* m_titleLabel = nullptr; + QLabel* m_stepCounterLabel = nullptr; + QProgressBar* m_progressBar = nullptr; + QPushButton* m_closeButton = nullptr; + QStackedWidget* m_stack = nullptr; + QPushButton* m_nextButton = nullptr; +}; + +#endif // LLWIZARD_H diff --git a/src/gui/llwizardpage.h b/src/gui/llwizardpage.h new file mode 100644 index 0000000..d4006ff --- /dev/null +++ b/src/gui/llwizardpage.h @@ -0,0 +1,156 @@ +/* + * SyntaxTutor - Interactive Tutorial About Syntax Analyzers + * Copyright (C) 2025 Jose R. (jose-rzm) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LLWIZARDPAGE_H +#define LLWIZARDPAGE_H + +#include +#include +#include +#include +#include +#include + +/** + * @class LLWizardPage + * @brief A single guided step for constructing the LL(1) table. + * + * This widget presents one (non-terminal, terminal) cell at a time with a + * compact explanation card and an answer card. The expected answer is the + * production that belongs in the cell; the input is validated ignoring + * whitespace so "a B", "a B" and "aB" are all accepted. It emits a signal + * whenever the completion state changes. + */ +class LLWizardPage : public QWidget { + Q_OBJECT + public: + /** + * @brief Constructs a page for a specific cell in the LL(1) table. + * + * @param nonTerminal The non-terminal symbol (row header). + * @param symbol The terminal symbol (column header). + * @param explanation A pedagogical explanation shown to the user. + * @param expected The expected production, space-separated (e.g. "a B" + * or "EPSILON"). + * @param parent The parent widget. + */ + LLWizardPage(const QString& nonTerminal, const QString& symbol, + const QString& explanation, const QString& expected, + QWidget* parent = nullptr) + : QWidget(parent), + m_title(tr("Fila %1, columna '%2'").arg(nonTerminal).arg(symbol)), + m_expected(expected) { + auto* rootLayout = new QVBoxLayout(this); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(22); + + auto* explanationTitle = new QLabel(tr("Pista"), this); + explanationTitle->setObjectName("llWizardSectionTitle"); + rootLayout->addWidget(explanationTitle); + + auto* explanationLabel = new QLabel(explanation, this); + explanationLabel->setObjectName("llWizardExplanationLabel"); + explanationLabel->setWordWrap(true); + rootLayout->addWidget(explanationLabel); + + auto* answerTitle = new QLabel(tr("Tu respuesta"), this); + answerTitle->setObjectName("llWizardSectionTitle"); + rootLayout->addWidget(answerTitle); + + m_edit = new QLineEdit(this); + m_edit->setObjectName("llWizardAnswerEdit"); + m_edit->setMinimumHeight(48); + m_edit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_edit->setPlaceholderText( + tr("Escribe la producción (EPSILON si es vacía)")); + rootLayout->addWidget(m_edit); + + m_feedback = new QLabel(this); + m_feedback->setObjectName("llWizardFeedbackLabel"); + m_feedback->setWordWrap(true); + m_feedback->setMinimumHeight(40); + m_feedback->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + rootLayout->addWidget(m_feedback); + + rootLayout->addStretch(1); + + connect(m_edit, &QLineEdit::textChanged, this, + &LLWizardPage::onTextChanged); + connect(m_edit, &QLineEdit::returnPressed, this, + &LLWizardPage::submitRequested); + } + + QString titleText() const { return m_title; } + bool isComplete() const { return m_isComplete; } + void focusAnswerField() { m_edit->setFocus(); } + +#ifdef SYNTAXTUTOR_TESTING + QString expectedForTest() const { return m_expected; } +#endif + + signals: + void completionChanged(bool complete); + void submitRequested(); + + private slots: + /** + * @brief Checks the user's input and updates inline feedback. + * + * The comparison ignores whitespace, matching the tolerant parsing the + * table cells already accept. + * + * @param text The current user input. + */ + void onTextChanged(const QString& text) { + const QString trimmed = text.trimmed(); + const bool correct = (compact(trimmed) == compact(m_expected)); + + if (trimmed.isEmpty()) { + m_feedback->clear(); + } else if (correct) { + m_feedback->setText( + tr("✔ Respuesta correcta, pasa a la siguiente pregunta")); + } else { + m_feedback->setText(tr("✘ Incorrecto, repasa los símbolos " + "directores (SD) en el panel del tutor.")); + } + + if (m_isComplete == correct) { + return; + } + + m_isComplete = correct; + emit completionChanged(m_isComplete); + } + + private: + static QString compact(const QString& text) { + QString result = text; + result.remove(QRegularExpression("\\s+")); + return result; + } + + QString m_title; ///< Header title for this guided step. + QString m_expected; ///< Expected production, space-separated. + QLabel* m_feedback; ///< Inline feedback label for answer validation. + QLineEdit* m_edit; ///< Input field for the user's answer. + bool m_isComplete = + false; ///< Whether the user has entered the correct response. +}; + +#endif // LLWIZARDPAGE_H From b5f5049d055002385d1800921e1f5a18f8225cea Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:36:50 +0200 Subject: [PATCH 2/3] feat: add guided mode --- resources/styles/app.qss | 48 ++++++++++++++++++++++++++------------- src/gui/lltabledialog.cpp | 29 ++++++++++++++++++++++- src/gui/lltabledialog.h | 23 +++++++++++++++++++ src/gui/lltutorwindow.cpp | 33 +++++++++++++++++++++++++++ src/gui/lltutorwindow.h | 13 +++++++++++ 5 files changed, 129 insertions(+), 17 deletions(-) diff --git a/resources/styles/app.qss b/resources/styles/app.qss index 10ec484..4a8eae1 100644 --- a/resources/styles/app.qss +++ b/resources/styles/app.qss @@ -519,41 +519,48 @@ QTextBrowser#infoDialogContent a { color: #36C5CC; } -QDialog[guidedDialog="slr"] { +QDialog[guidedDialog="slr"], +QDialog[guidedDialog="ll"] { background-color: #1F1F1F; border: none; } -QDialog[guidedDialog="slr"] QFrame#slrWizardPanel { +QDialog[guidedDialog="slr"] QFrame#slrWizardPanel, +QDialog[guidedDialog="ll"] QFrame#llWizardPanel { background: transparent; border: none; border-radius: 0; } -QDialog[guidedDialog="slr"] QLabel#slrWizardTitle { +QDialog[guidedDialog="slr"] QLabel#slrWizardTitle, +QDialog[guidedDialog="ll"] QLabel#llWizardTitle { color: #F4F7F8; font-size: 28px; font-weight: 700; } -QDialog[guidedDialog="slr"] QLabel#slrWizardStepCounter { +QDialog[guidedDialog="slr"] QLabel#slrWizardStepCounter, +QDialog[guidedDialog="ll"] QLabel#llWizardStepCounter { color: #9AA5A8; font-size: 12px; font-weight: 600; } -QDialog[guidedDialog="slr"] QProgressBar#slrWizardProgress { +QDialog[guidedDialog="slr"] QProgressBar#slrWizardProgress, +QDialog[guidedDialog="ll"] QProgressBar#llWizardProgress { background-color: rgba(255, 255, 255, 0.05); border: none; border-radius: 2px; } -QDialog[guidedDialog="slr"] QProgressBar#slrWizardProgress::chunk { +QDialog[guidedDialog="slr"] QProgressBar#slrWizardProgress::chunk, +QDialog[guidedDialog="ll"] QProgressBar#llWizardProgress::chunk { background-color: #11B3BC; border-radius: 2px; } -QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton { +QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton, +QDialog[guidedDialog="ll"] QPushButton#llWizardCloseButton { background: transparent; color: #AAB5B8; border: 1px solid rgba(255, 255, 255, 0.08); @@ -563,17 +570,20 @@ QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton { font-weight: 600; } -QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton:hover { +QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton:hover, +QDialog[guidedDialog="ll"] QPushButton#llWizardCloseButton:hover { background-color: rgba(255, 255, 255, 0.03); color: #FFFFFF; border: 1px solid rgba(255, 255, 255, 0.12); } -QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton:pressed { +QDialog[guidedDialog="slr"] QPushButton#slrWizardCloseButton:pressed, +QDialog[guidedDialog="ll"] QPushButton#llWizardCloseButton:pressed { background-color: rgba(255, 255, 255, 0.06); } -QDialog[guidedDialog="slr"] QLabel#slrWizardSectionTitle { +QDialog[guidedDialog="slr"] QLabel#slrWizardSectionTitle, +QDialog[guidedDialog="ll"] QLabel#llWizardSectionTitle { color: #9FCFD2; font-size: 12px; font-weight: 700; @@ -581,13 +591,15 @@ QDialog[guidedDialog="slr"] QLabel#slrWizardSectionTitle { text-transform: uppercase; } -QDialog[guidedDialog="slr"] QLabel#slrWizardExplanationLabel { +QDialog[guidedDialog="slr"] QLabel#slrWizardExplanationLabel, +QDialog[guidedDialog="ll"] QLabel#llWizardExplanationLabel { color: #DEE5E7; font-size: 15px; line-height: 1.5; } -QDialog[guidedDialog="slr"] QLineEdit#slrWizardAnswerEdit { +QDialog[guidedDialog="slr"] QLineEdit#slrWizardAnswerEdit, +QDialog[guidedDialog="ll"] QLineEdit#llWizardAnswerEdit { background-color: #2B3133; color: #F1F4F5; border: 1px solid rgba(255, 255, 255, 0.10); @@ -597,23 +609,27 @@ QDialog[guidedDialog="slr"] QLineEdit#slrWizardAnswerEdit { selection-background-color: #35515A; } -QDialog[guidedDialog="slr"] QLineEdit#slrWizardAnswerEdit:focus { +QDialog[guidedDialog="slr"] QLineEdit#slrWizardAnswerEdit:focus, +QDialog[guidedDialog="ll"] QLineEdit#llWizardAnswerEdit:focus { border: 1px solid #11B3BC; } -QDialog[guidedDialog="slr"] QLabel#slrWizardFeedbackLabel { +QDialog[guidedDialog="slr"] QLabel#slrWizardFeedbackLabel, +QDialog[guidedDialog="ll"] QLabel#llWizardFeedbackLabel { color: #AEB9BC; min-height: 40px; padding-top: 2px; } -QDialog[guidedDialog="slr"] QPushButton#slrWizardNextButton { +QDialog[guidedDialog="slr"] QPushButton#slrWizardNextButton, +QDialog[guidedDialog="ll"] QPushButton#llWizardNextButton { min-width: 136px; padding: 11px 18px; border-radius: 10px; } -QDialog[guidedDialog="slr"] QPushButton#slrWizardNextButton:disabled { +QDialog[guidedDialog="slr"] QPushButton#slrWizardNextButton:disabled, +QDialog[guidedDialog="ll"] QPushButton#llWizardNextButton:disabled { background-color: #2A2E30; color: #6F7A7E; border: 1px solid #353D40; diff --git a/src/gui/lltabledialog.cpp b/src/gui/lltabledialog.cpp index aa7ddcd..c18ebd4 100644 --- a/src/gui/lltabledialog.cpp +++ b/src/gui/lltabledialog.cpp @@ -69,9 +69,22 @@ LLTableDialog::LLTableDialog(const QStringList& rowHeaders, submitButton->setCursor(Qt::PointingHandCursor); submitButton->setProperty("role", "primary"); + guidedButton = new QPushButton(tr("Modo guiado"), this); + guidedButton->setObjectName("llTableGuidedButton"); + guidedButton->setCursor(Qt::PointingHandCursor); + guidedButton->setProperty("role", "primary"); + QVBoxLayout* layout = new QVBoxLayout; layout->addWidget(table); - layout->addWidget(submitButton); + + auto* buttons = new QHBoxLayout; + buttons->setContentsMargins(0, 0, 0, 0); + buttons->setSpacing(10); + buttons->addStretch(); + buttons->addWidget(guidedButton); + buttons->addWidget(submitButton); + + layout->addLayout(buttons); layout->setContentsMargins(10, 10, 10, 10); setLayout(layout); @@ -104,6 +117,20 @@ LLTableDialog::LLTableDialog(const QStringList& rowHeaders, commitPendingEdit(); emit submitted(getTableData()); }); + connect(guidedButton, &QPushButton::clicked, this, + [this]() { + commitPendingEdit(); + emit guidedRequested(getTableData()); + }); +} + +void LLTableDialog::setGuidedModeActive(bool active) { + guidedButton->setEnabled(!active); + submitButton->setEnabled(!active); +} + +void LLTableDialog::setGuidedButtonVisible(bool visible) { + guidedButton->setVisible(visible); } void LLTableDialog::commitPendingEdit() { diff --git a/src/gui/lltabledialog.h b/src/gui/lltabledialog.h index 8efd7e6..543d81a 100644 --- a/src/gui/lltabledialog.h +++ b/src/gui/lltabledialog.h @@ -75,6 +75,22 @@ class LLTableDialog : public QDialog { */ void highlightIncorrectCells(const QList>& coords); + /** + * @brief Enables or disables the dialog buttons while the guided mode + * dialog is open. + * @param active Whether the guided mode is currently active. + */ + void setGuidedModeActive(bool active); + + /** + * @brief Shows or hides the guided mode button. + * + * Exam mode hides it: the guided walkthrough would reveal the answers. + * + * @param visible Whether the button should be visible. + */ + void setGuidedButtonVisible(bool visible); + signals: /** * @brief Signal emitted when the user submits the table. @@ -82,11 +98,18 @@ class LLTableDialog : public QDialog { */ void submitted(const QVector>& data); + /** + * @brief Signal emitted when the user asks for the guided mode. + * @param data The current table data, restored when the wizard closes. + */ + void guidedRequested(const QVector>& data); + private: void commitPendingEdit(); QTableWidget* table; ///< The widget representing the LL(1) parsing table. QPushButton* submitButton; ///< Button to submit the completed table. + QPushButton* guidedButton; ///< Button to launch guided mode. }; #endif // LLTABLEDIALOG_H diff --git a/src/gui/lltutorwindow.cpp b/src/gui/lltutorwindow.cpp index 36284b5..5b17918 100644 --- a/src/gui/lltutorwindow.cpp +++ b/src/gui/lltutorwindow.cpp @@ -19,6 +19,7 @@ #include "lltutorwindow.h" #include "examreportdialog.h" #include "grammarview.h" +#include "llwizard.h" #include "tutorialmanager.h" #include "ui_lltutorwindow.h" #include @@ -611,6 +612,26 @@ void LLTutorWindow::addMessage(const QString& text, bool isUser) { ui->listWidget->scrollToBottom(); } +void LLTutorWindow::connectGuidedMode(LLTableDialog* dialog, + const QStringList& colHeaders) { + connect(dialog, &LLTableDialog::guidedRequested, this, + [this, dialog, colHeaders](const QVector>& data) { + const QVector> snapshot = data; + auto* wizard = + new LLWizard(ll1, sortedNonTerminals, colHeaders, dialog); + wizard->setAttribute(Qt::WA_DeleteOnClose); + wizard->setWindowModality(Qt::WindowModal); + dialog->setGuidedModeActive(true); + + connect(wizard, &QDialog::finished, dialog, + [dialog, snapshot](int) { + dialog->setGuidedModeActive(false); + dialog->setInitialData(snapshot); + }); + wizard->show(); + }); +} + void LLTutorWindow::showTableForCPrime() { QStringList colHeaders; @@ -623,6 +644,12 @@ void LLTutorWindow::showTableForCPrime() { colHeaders.sort(); auto* dialog = new LLTableDialog(sortedNonTerminals, colHeaders, this, &rawTable); + if (examMode) { + // The guided walkthrough reveals the answers; not during an exam. + dialog->setGuidedButtonVisible(false); + } + + connectGuidedMode(dialog, colHeaders); connect(dialog, &LLTableDialog::submitted, this, [this, dialog, colHeaders](const QVector>& data) { @@ -676,6 +703,12 @@ void LLTutorWindow::showTable() { auto* dialog = new LLTableDialog(sortedNonTerminals, colHeaders, this, &rawTable); currentDlg = dialog; + if (examMode) { + // The guided walkthrough reveals the answers; not during an exam. + dialog->setGuidedButtonVisible(false); + } + + connectGuidedMode(dialog, colHeaders); connect(dialog, &LLTableDialog::submitted, this, [this, colHeaders](const QVector>& data) { diff --git a/src/gui/lltutorwindow.h b/src/gui/lltutorwindow.h index 9d021f2..9541e84 100644 --- a/src/gui/lltutorwindow.h +++ b/src/gui/lltutorwindow.h @@ -206,6 +206,19 @@ class LLTutorWindow : public QWidget { void handleTableSubmission(const QVector>& raw, const QStringList& colHeaders); + + /** + * @brief Hooks the table dialog's guided mode button to the LL wizard. + * + * Opens the step-by-step LL(1) table assistant when requested, freezing + * the table dialog while it is active and restoring the user's snapshot + * when it closes. + * + * @param dialog Table dialog to connect. + * @param colHeaders Terminal symbols in the dialog's column order. + */ + void connectGuidedMode(LLTableDialog* dialog, + const QStringList& colHeaders); void updatePlaceholder(); bool confirmExitToHome(); QString promptExportFilePath() const; From e9dc8b4db6f7da41e13adf5f691566226a1cc988 Mon Sep 17 00:00:00 2001 From: jose-rZM <100773386+jose-rZM@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:37:07 +0200 Subject: [PATCH 3/3] test: add ll guided mode tests --- tests/helpers/qt_modal_test_utils.h | 11 ++ tests/tests.pro | 2 + tests/tutor/ll_tutor_window_test.cpp | 257 +++++++++++++++++++++++++++ tests/tutor/tutor_window_test.h | 3 + 4 files changed, 273 insertions(+) diff --git a/tests/helpers/qt_modal_test_utils.h b/tests/helpers/qt_modal_test_utils.h index 6af54cc..06267b6 100644 --- a/tests/helpers/qt_modal_test_utils.h +++ b/tests/helpers/qt_modal_test_utils.h @@ -252,4 +252,15 @@ inline void requestSlrGuidedMode(SLRTableDialog* dialog, QTest::mouseClick(button, Qt::LeftButton); } +inline void requestLlGuidedMode(LLTableDialog* dialog, + const QVector>& raw) { + auto* table = dialog->findChild("llTableWidget"); + auto* button = dialog->findChild("llTableGuidedButton"); + QVERIFY(table != nullptr); + QVERIFY(button != nullptr); + + setTableData(table, raw); + QTest::mouseClick(button, Qt::LeftButton); +} + } // namespace QtModalTestUtils diff --git a/tests/tests.pro b/tests/tests.pro index 7fe54f3..b8e4f1f 100644 --- a/tests/tests.pro +++ b/tests/tests.pro @@ -63,6 +63,8 @@ HEADERS += \ ../src/gui/grammareditordialog.h \ ../src/gui/lltabledialog.h \ ../src/gui/lltutorwindow.h \ + ../src/gui/llwizard.h \ + ../src/gui/llwizardpage.h \ ../src/gui/mainwindow.h \ ../src/gui/slrtabledialog.h \ ../src/gui/slrtutorwindow.h \ diff --git a/tests/tutor/ll_tutor_window_test.cpp b/tests/tutor/ll_tutor_window_test.cpp index 455ebfa..ef532cc 100644 --- a/tests/tutor/ll_tutor_window_test.cpp +++ b/tests/tutor/ll_tutor_window_test.cpp @@ -3,6 +3,7 @@ #include "examreportdialog.h" #include "ll1_tutor_test_utils.h" #include "lltutorwindow.h" +#include "llwizard.h" #include "qt_modal_test_utils.h" #include "tutor_grammar_fixtures.h" #include "tutor_scenario.h" @@ -12,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -872,3 +875,257 @@ void TutorWindowTest::llExamModeAllCorrectScoresTen() { QTest::qWait(20); }); } + +namespace { + +LLWizard* waitForLlWizard() { + return QtModalTestUtils::waitForVisibleTopLevelWidget(); +} + +void finishLlWizard(LLWizard* wizard) { + QPointer wizardGuard(wizard); + QVERIFY(wizardGuard != nullptr); + int guard = 0; + while (wizardGuard != nullptr && wizardGuard->isVisible() && + ++guard < 200) { + auto* page = wizardGuard->currentPage(); + QVERIFY(page != nullptr); + auto* edit = page->findChild("llWizardAnswerEdit"); + QVERIFY(edit != nullptr); + edit->setText(page->expectedForTest()); + QApplication::processEvents(); + + const bool isFinalPage = wizardGuard->isOnLastPage(); + QPushButton* nextButton = wizardGuard->nextButton(); + QVERIFY(nextButton != nullptr); + QTest::mouseClick(nextButton, Qt::LeftButton); + QApplication::processEvents(); + + if (isFinalPage) { + break; + } + } +} + +} // namespace + +// ----------------------------------------------------------------------------- +// Case: LL1-TC-19 +// Summary: +// Exercises the LL(1) guided mode chrome: opening from the table dialog, +// inline feedback, whitespace-tolerant validation, and exiting early. +// +// Situation: +// LL(1) tutor in C with the table dialog open on the simple fixture. +// +// Action: +// The user opens guided mode, types a wrong answer, then the expected +// production with extra spaces, advances one page and exits. +// +// Expected: +// The wizard freezes the table dialog while open, shows feedback for wrong +// input, accepts the spaced variant, and returns to the table on exit. +// ----------------------------------------------------------------------------- +void TutorWindowTest::llGuidedModeWizardUsesCustomNavigationAndAllowsExit() { + 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"); + auto* guidedButton = dialog->findChild("llTableGuidedButton"); + auto* submitButton = dialog->findChild("llTableSubmitButton"); + QVERIFY(table != nullptr); + QVERIFY(guidedButton != nullptr); + QVERIFY(submitButton != nullptr); + + QtModalTestUtils::requestLlGuidedMode( + dialog, QVector>( + table->rowCount(), QVector(table->columnCount()))); + LLWizard* wizard = waitForLlWizard(); + QVERIFY(wizard != nullptr); + QPointer wizardGuard(wizard); + auto* page = wizard->currentPage(); + QVERIFY(page != nullptr); + + QVERIFY(!guidedButton->isEnabled()); + QVERIFY(!submitButton->isEnabled()); + + auto* titleLabel = wizard->findChild("llWizardTitle"); + QVERIFY(titleLabel != nullptr); + QVERIFY(!titleLabel->text().isEmpty()); + + auto* stepCounter = wizard->findChild("llWizardStepCounter"); + QVERIFY(stepCounter != nullptr); + QVERIFY(!stepCounter->text().isEmpty()); + + auto* feedbackLabel = page->findChild("llWizardFeedbackLabel"); + QVERIFY(feedbackLabel != nullptr); + QVERIFY(feedbackLabel->text().isEmpty()); + + auto* edit = page->findChild("llWizardAnswerEdit"); + QVERIFY(edit != nullptr); + edit->setText(QStringLiteral("wrong")); + QApplication::processEvents(); + QVERIFY(!feedbackLabel->text().isEmpty()); + QVERIFY(!wizard->nextButton()->isEnabled()); + + // Whitespace-tolerant validation: pad the expected production. + const QString spaced = + QStringLiteral(" %1 ").arg(page->expectedForTest()).replace(' ', + " "); + edit->setText(spaced); + QApplication::processEvents(); + QVERIFY(wizard->nextButton()->isEnabled()); + + QPointer firstPage(page); + QTest::keyClick(edit, Qt::Key_Return); + QTRY_VERIFY(firstPage == nullptr || wizard->currentPage() != firstPage); + + QTest::mouseClick(wizard->closeButton(), Qt::LeftButton); + QTRY_VERIFY(wizardGuard == nullptr || !wizardGuard->isVisible()); + QVERIFY(guidedButton->isEnabled()); + QVERIFY(submitButton->isEnabled()); + QVERIFY(dialog->isVisible()); + QCOMPARE(tutor.currentStateForTest(), QString("C")); +} + +// ----------------------------------------------------------------------------- +// Case: LL1-TC-20 +// Summary: +// Completes the LL(1) guided mode and verifies the table dialog is restored +// with the user's snapshot. +// +// Situation: +// LL(1) tutor in C with the table dialog open on each fixture. +// +// Action: +// The user opens guided mode and answers every cell correctly. +// +// Expected: +// The wizard walks every non-empty cell of the LL(1) table, closes after +// the last page, and control returns to the table dialog still in C. +// ----------------------------------------------------------------------------- +void TutorWindowTest::llGuidedModeWizardCompletesAndReturnsToTable() { + 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"); + auto* guidedButton = + dialog->findChild("llTableGuidedButton"); + auto* submitButton = + dialog->findChild("llTableSubmitButton"); + QVERIFY(table != nullptr); + QVERIFY(guidedButton != nullptr); + QVERIFY(submitButton != nullptr); + + QtModalTestUtils::requestLlGuidedMode( + dialog, + QVector>(table->rowCount(), + QVector(table->columnCount()))); + LLWizard* wizard = waitForLlWizard(); + QVERIFY(wizard != nullptr); + QPointer wizardGuard(wizard); + + QVERIFY(!guidedButton->isEnabled()); + QVERIFY(!submitButton->isEnabled()); + + finishLlWizard(wizard); + QTRY_VERIFY(wizardGuard == nullptr || !wizardGuard->isVisible()); + QVERIFY(guidedButton->isEnabled()); + QVERIFY(submitButton->isEnabled()); + QVERIFY(dialog->isVisible()); + QCOMPARE(tutor.currentStateForTest(), QString("C")); + + // The exercise still finishes normally after using the wizard. + QtModalTestUtils::submitLlTableDialog( + dialog, QtModalTestUtils::buildExpectedTable(grammar, table)); + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + }); +} + +// ----------------------------------------------------------------------------- +// Case: LL1-TC-21 +// Summary: +// Verifies the guided mode is reachable from the C' retry table and that +// exam mode hides the guided button. +// +// Situation: +// LL(1) tutor driven to C' on the simple fixture; a second tutor in exam +// mode at the C table. +// +// Action: +// The test opens guided mode from the C' dialog, then checks the exam +// tutor's table dialog. +// +// Expected: +// The wizard opens in C'; in exam mode the guided button is hidden. +// ----------------------------------------------------------------------------- +void TutorWindowTest::llGuidedModeAvailableInCPrimeAndHiddenInExam() { + const Grammar grammar = TutorGrammarFixtures::makeLl1SimpleGrammar(); + { + LLTutorWindow tutor(grammar, nullptr); + runScenario(tutor, {{Ll1TutorTestUtils::tableSizeAnswer(grammar), "B", + 1, 0}}); + answerRemainingRulesUntilStateC(tutor, grammar); + driveTutorToCPrime(tutor, grammar); + + LLTableDialog* dialog = waitForTableDialog(); + auto* table = dialog->findChild("llTableWidget"); + auto* guidedButton = + dialog->findChild("llTableGuidedButton"); + QVERIFY(table != nullptr); + QVERIFY(guidedButton != nullptr); + QVERIFY(guidedButton->isVisible()); + + QtModalTestUtils::requestLlGuidedMode( + dialog, + QVector>(table->rowCount(), + QVector(table->columnCount()))); + LLWizard* wizard = waitForLlWizard(); + QVERIFY(wizard != nullptr); + QPointer wizardGuard(wizard); + QTest::mouseClick(wizard->closeButton(), Qt::LeftButton); + QTRY_VERIFY(wizardGuard == nullptr || !wizardGuard->isVisible()); + QVERIFY(dialog->isVisible()); + + // Leave the exercise cleanly. + QtModalTestUtils::submitLlTableDialog( + dialog, QtModalTestUtils::buildExpectedTable(grammar, table)); + QTRY_COMPARE(tutor.currentStateForTest(), QString("fin")); + } + + LLTutorWindow examTutor(grammar, nullptr, nullptr, true); + examTutor.setAnswerForTest(Ll1TutorTestUtils::tableSizeAnswer(grammar)); + examTutor.submitForTest(); + answerRemainingRulesUntilStateC(examTutor, grammar); + QCOMPARE(examTutor.currentStateForTest(), QString("C")); + + LLTableDialog* examDialog = waitForTableDialog(); + QVERIFY(examDialog != nullptr); + auto* examGuidedButton = + examDialog->findChild("llTableGuidedButton"); + QVERIFY(examGuidedButton != nullptr); + QVERIFY(!examGuidedButton->isVisible()); + + auto* examTable = examDialog->findChild("llTableWidget"); + QVERIFY(examTable != nullptr); + QtModalTestUtils::submitLlTableDialog( + examDialog, + QtModalTestUtils::buildExpectedTable(grammar, examTable)); + QTRY_COMPARE(examTutor.currentStateForTest(), QString("fin")); + auto* report = + QtModalTestUtils::waitForVisibleTopLevelWidget(); + QVERIFY(report != nullptr); + QTest::mouseClick(report->findChild("examReportCloseButton"), + Qt::LeftButton); +} diff --git a/tests/tutor/tutor_window_test.h b/tests/tutor/tutor_window_test.h index d8ffee3..d8869cd 100644 --- a/tests/tutor/tutor_window_test.h +++ b/tests/tutor/tutor_window_test.h @@ -24,6 +24,9 @@ class TutorWindowTest : public QObject { void llAcceptsFlexibleTableCellFormatting(); void llExamModeWrongAnswersFollowMainPathAndGradeZero(); void llExamModeAllCorrectScoresTen(); + void llGuidedModeWizardUsesCustomNavigationAndAllowsExit(); + void llGuidedModeWizardCompletesAndReturnsToTable(); + void llGuidedModeAvailableInCPrimeAndHiddenInExam(); void slrCreatesTutorWithNullTutorialManager(); void slrStateAErrorPathAdvancesThroughAprime();