diff --git a/SyntaxTutor.pro b/SyntaxTutor.pro index 96e50ac2..c92a0731 100644 --- a/SyntaxTutor.pro +++ b/SyntaxTutor.pro @@ -31,8 +31,10 @@ SOURCES += \ src/backend/lr0_item.cpp \ src/backend/slr1_parser.cpp \ src/backend/symbol_table.cpp \ + src/widgets/automatonview.cpp \ src/widgets/customtextedit.cpp \ src/widgets/grammarview.cpp \ + src/gui/automatonviewerdialog.cpp \ src/gui/examreportdialog.cpp \ src/gui/grammareditordialog.cpp \ src/gui/lltabledialog.cpp \ @@ -54,8 +56,10 @@ HEADERS += \ src/backend/slr1_parser.hpp \ src/backend/state.hpp \ src/backend/symbol_table.hpp \ + src/widgets/automatonview.h \ src/widgets/customtextedit.h \ src/widgets/grammarview.h \ + src/gui/automatonviewerdialog.h \ src/gui/examreportdialog.h \ src/gui/examsession.h \ src/gui/grammareditordialog.h \ diff --git a/resources/styles/app.qss b/resources/styles/app.qss index 4a8eae13..0dae5828 100644 --- a/resources/styles/app.qss +++ b/resources/styles/app.qss @@ -846,3 +846,65 @@ QDialog[examReport="true"] QPushButton#examReportCloseButton:hover { color: #FFFFFF; border: 1px solid rgba(255, 255, 255, 0.12); } + +QPushButton#automatonButton { + background-color: rgba(17, 179, 188, 0.12); + color: #11B3BC; + border: 1px solid rgba(17, 179, 188, 0.35); + border-radius: 8px; + padding: 8px 14px; + font-size: 12px; + font-weight: 600; +} + +QPushButton#automatonButton:hover { + background-color: rgba(17, 179, 188, 0.2); + color: #36C5CC; +} + +QPushButton#automatonButton:pressed { + background-color: rgba(17, 179, 188, 0.28); +} + +QPushButton#automatonButton:disabled { + background-color: #2A2E30; + color: #6F7A7E; + border: 1px solid #353D40; +} + +QGraphicsView#slrAutomatonView { + background-color: #212526; + border: 1px solid #303638; + border-radius: 12px; +} + +QDialog[automatonViewer="true"] { + background-color: #1F1F1F; +} + +QDialog[automatonViewer="true"] QLabel#automatonViewerHint { + color: #6F7A7E; + font-size: 12px; +} + +QDialog[automatonViewer="true"] QPushButton#automatonViewerToolButton { + background-color: #2B3133; + color: #E8ECEE; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 6px 12px; + font-size: 13px; + font-weight: 600; + min-width: 28px; +} + +QDialog[automatonViewer="true"] QPushButton#automatonViewerToolButton:hover { + background-color: #343B3D; + border: 1px solid rgba(17, 179, 188, 0.4); + color: #36C5CC; +} + +QDialog[automatonViewer="true"] QPushButton#automatonViewerToolButton:pressed { + background-color: #11B3BC; + color: #07343A; +} diff --git a/src/gui/automatonviewerdialog.cpp b/src/gui/automatonviewerdialog.cpp new file mode 100644 index 00000000..52d5dc5e --- /dev/null +++ b/src/gui/automatonviewerdialog.cpp @@ -0,0 +1,91 @@ +/* + * SyntaxTutor - Interactive Tutorial About Syntax Analyzers + * Copyright (C) 2025 Jose R. (jose-rzm) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "automatonviewerdialog.h" +#include "automatonview.h" + +#include +#include +#include +#include + +AutomatonViewerDialog::AutomatonViewerDialog(AutomatonView* view, + QWidget* parent) + : QDialog(parent), view_(view) { + setObjectName("automatonViewerDialog"); + setProperty("automatonViewer", true); + setWindowTitle(tr("Autómata LR(0)")); + setModal(false); + resize(820, 620); + setMinimumSize(520, 420); + + auto* rootLayout = new QVBoxLayout(this); + rootLayout->setContentsMargins(16, 14, 16, 16); + rootLayout->setSpacing(12); + + auto* toolbar = new QHBoxLayout(); + toolbar->setSpacing(8); + + auto* hint = new QLabel( + tr("Ctrl + rueda para acercar, arrastra para desplazar"), this); + hint->setObjectName("automatonViewerHint"); + toolbar->addWidget(hint); + toolbar->addStretch(1); + + auto makeToolButton = [this](const QString& text, const QString& tip) { + auto* button = new QPushButton(text, this); + button->setObjectName("automatonViewerToolButton"); + button->setToolTip(tip); + button->setCursor(Qt::PointingHandCursor); + button->setAutoDefault(false); + button->setDefault(false); + return button; + }; + + auto* zoomOutButton = makeToolButton(QStringLiteral("−"), tr("Alejar")); + auto* zoomInButton = makeToolButton(QStringLiteral("+"), tr("Acercar")); + auto* fitButton = makeToolButton(tr("Ajustar"), tr("Ajustar a la vista")); + auto* centerButton = + makeToolButton(tr("Centrar estado"), tr("Centrar el estado actual")); + + toolbar->addWidget(zoomOutButton); + toolbar->addWidget(zoomInButton); + toolbar->addWidget(fitButton); + toolbar->addWidget(centerButton); + rootLayout->addLayout(toolbar); + + view_->setParent(this); + rootLayout->addWidget(view_, 1); + view_->show(); + + connect(zoomOutButton, &QPushButton::clicked, view_, + &AutomatonView::zoomOut); + connect(zoomInButton, &QPushButton::clicked, view_, &AutomatonView::zoomIn); + connect(fitButton, &QPushButton::clicked, view_, &AutomatonView::fitToView); + connect(centerButton, &QPushButton::clicked, view_, + &AutomatonView::centerOnCurrentState); +} + +AutomatonViewerDialog::~AutomatonViewerDialog() { + // The view is owned by the tutor, not by this dialog. Detach it so it + // survives the dialog and can be embedded again next time. + if (view_ != nullptr && view_->parent() == this) { + view_->hide(); + view_->setParent(nullptr); + } +} diff --git a/src/gui/automatonviewerdialog.h b/src/gui/automatonviewerdialog.h new file mode 100644 index 00000000..c01f40c6 --- /dev/null +++ b/src/gui/automatonviewerdialog.h @@ -0,0 +1,57 @@ +/* + * 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 AUTOMATONVIEWERDIALOG_H +#define AUTOMATONVIEWERDIALOG_H + +#include +#include + +class AutomatonView; + +/** + * @class AutomatonViewerDialog + * @brief Non-modal floating window that hosts the LR(0) automaton view. + * + * The dialog is a thin shell: it embeds an externally-owned AutomatonView + * (so its progressive reveal state is preserved across open/close cycles) + * and adds a toolbar with zoom in/out, fit-to-view and center-current + * actions. It never owns the view: on destruction it reparents the view out + * so the tutor can keep reusing it. Being non-modal, the student can keep it + * open while continuing the exercise. + */ +class AutomatonViewerDialog : public QDialog { + Q_OBJECT + public: + /** + * @brief Builds the viewer around an existing automaton view. + * + * @param view The automaton view to embed; ownership is not taken. + * @param parent Parent widget. + */ + explicit AutomatonViewerDialog(AutomatonView* view, + QWidget* parent = nullptr); + ~AutomatonViewerDialog() override; + + private: + // QPointer so the dialog never dereferences the borrowed view if it is + // destroyed first (e.g. by the tutor during teardown). + QPointer view_; +}; + +#endif // AUTOMATONVIEWERDIALOG_H diff --git a/src/gui/slrtutorwindow.cpp b/src/gui/slrtutorwindow.cpp index cd7af674..e91f142f 100644 --- a/src/gui/slrtutorwindow.cpp +++ b/src/gui/slrtutorwindow.cpp @@ -17,6 +17,7 @@ */ #include "slrtutorwindow.h" +#include "automatonviewerdialog.h" #include "examreportdialog.h" #include "grammarview.h" #include "slrwizard.h" @@ -27,6 +28,7 @@ #include #include #include +#include #include namespace { @@ -226,6 +228,7 @@ SLRTutorWindow::SLRTutorWindow(const Grammar& g, TutorialManager* tm, for (const Lr0Item& it : st.items_) { if (it.IsComplete() && it.antecedent_ != slr1.gr_.axiom_) { reduceStatesIdQueue.push(st.id_); + reduceStateIds.insert(st.id_); break; } } @@ -270,6 +273,8 @@ SLRTutorWindow::SLRTutorWindow(const Grammar& g, TutorialManager* tm, ui->gr->setFixedWidth(grammarView->sizeHint().width() + 32); ui->gr->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + setupAutomatonPanel(); + // ====== Status, Progress & First Message =================== ui->cntRight->setText(QString::number(cntRightAnswers)); ui->cntWrong->setText(QString::number(cntWrongAnswers)); @@ -299,6 +304,19 @@ SLRTutorWindow::SLRTutorWindow(const Grammar& g, TutorialManager* tm, } SLRTutorWindow::~SLRTutorWindow() { + // Tear down the automaton viewer (if open) and the view it borrows. The + // viewer's destructor detaches the view, so we then own it outright and + // delete it explicitly to avoid any ambiguous ownership. + if (automatonViewer != nullptr) { + disconnect(automatonViewer, nullptr, this, nullptr); + delete automatonViewer; + automatonViewer = nullptr; + } + if (automatonView != nullptr) { + automatonView->setParent(nullptr); + delete automatonView; + automatonView = nullptr; + } delete ui; } @@ -986,11 +1004,144 @@ void SLRTutorWindow::updateProgressPanel() { ui->textEdit->verticalScrollBar()->setValue(scrollPos); } +void SLRTutorWindow::setupAutomatonPanel() { + if (examMode) { + // No automaton (and no button) during an exam. + ui->automatonButton->hide(); + return; + } + + // The view is parented to the tutor but never placed in the main layout; + // it stays hidden until embedded in the floating viewer. Keeping it as a + // tutor child lets its progressive reveal state survive open/close. + automatonView = new AutomatonView(this); + automatonView->hide(); + + QVector stateInfos; + stateInfos.reserve(static_cast(slr1.states_.size())); + for (const state& st : slr1.states_) { + stateInfos.append( + {st.id_, QString::fromStdString(slr1.PrintItems(st.items_))}); + } + + QVector transitionInfos; + for (const auto& [from, transitions] : slr1.transitions_) { + for (const auto& [symbol, to] : transitions) { + transitionInfos.append( + {from, QString::fromStdString(symbol), to}); + } + } + + automatonView->setAutomaton(stateInfos, transitionInfos); + + ui->automatonButton->setCursor(Qt::PointingHandCursor); + connect(ui->automatonButton, &QPushButton::clicked, this, + &SLRTutorWindow::openAutomatonViewer); + updateAutomatonButton(); +} + +void SLRTutorWindow::updateAutomatonButton() { + if (automatonView == nullptr) { + return; + } + // Available only once the student has constructed the initial state I0. + ui->automatonButton->setEnabled(automatonView->visibleStateCount() > 0); +} + +void SLRTutorWindow::openAutomatonViewer() { + if (automatonView == nullptr) { + return; // exam mode + } + if (automatonViewer != nullptr) { + // Already open: focus/raise instead of duplicating. + automatonViewer->raise(); + automatonViewer->activateWindow(); + return; + } + + automatonViewer = new AutomatonViewerDialog(automatonView, this); + connect(automatonViewer, &QDialog::finished, this, [this](int) { + // Reclaim the view so it survives the viewer and can be reopened. + if (automatonView != nullptr) { + automatonView->setParent(this); + automatonView->hide(); + } + if (automatonViewer != nullptr) { + automatonViewer->deleteLater(); + automatonViewer = nullptr; + } + }); + automatonViewer->show(); + automatonViewer->raise(); + automatonViewer->activateWindow(); + // Fit once the viewer has its real size. + QTimer::singleShot(0, automatonView, &AutomatonView::fitToView); +} + +void SLRTutorWindow::updateAutomatonPanel() { + if (automatonView == nullptr) { + return; + } + + switch (currentState) { + // The student is analyzing one specific state of the collection. + case StateSlr::C: + case StateSlr::CA: + case StateSlr::CB: + automatonView->setCurrentState(static_cast(currentStateId)); + break; + + // From D onwards the collection is complete: full consultation mode. + case StateSlr::D: + case StateSlr::D1: + case StateSlr::D2: + case StateSlr::D_prime: + case StateSlr::E: + case StateSlr::E1: + case StateSlr::E2: + case StateSlr::H: + case StateSlr::H_prime: + automatonView->revealAll(); + automatonView->clearStateMarks(); + automatonView->setCurrentState(-1); + break; + + case StateSlr::F: + automatonView->revealAll(); + automatonView->clearStateMarks(); + automatonView->setConflictStates(solutionForF()); + automatonView->setCurrentState(-1); + break; + + case StateSlr::FA: + automatonView->revealAll(); + automatonView->setConflictStates(solutionForF()); + automatonView->setCurrentState( + static_cast(currentConflictStateId)); + break; + + case StateSlr::G: + automatonView->revealAll(); + automatonView->clearStateMarks(); + automatonView->setReduceStates(reduceStateIds); + automatonView->setCurrentState(static_cast(currentReduceStateId)); + break; + + default: + automatonView->setCurrentState(-1); + break; + } +} + void SLRTutorWindow::addUserState(unsigned id) { auto st = std::ranges::find_if( slr1.states_, [id](const state& st) { return st.id_ == id; }); if (st != slr1.states_.end()) { userMadeStates.insert(*st); + if (automatonView != nullptr) { + automatonView->revealState(id); + updateAutomatonButton(); + } updateProgressPanel(); } } @@ -999,6 +1150,9 @@ void SLRTutorWindow::addUserTransition(unsigned fromId, const std::string& symbol, unsigned toId) { userMadeTransitions[fromId][symbol] = toId; + if (automatonView != nullptr) { + automatonView->revealTransition(fromId, toId); + } } void SLRTutorWindow::addMessage(const QString& text, bool isUser) { @@ -1499,6 +1653,9 @@ void SLRTutorWindow::on_confirmButton_clicked() { void SLRTutorWindow::postQuestion() { currentQuestionText = generateQuestion(); + // generateQuestion() refreshes the per-state context (currentStateId, + // conflict/reduce ids), so the automaton panel syncs right after it. + updateAutomatonPanel(); addMessage(currentQuestionText, false); } diff --git a/src/gui/slrtutorwindow.h b/src/gui/slrtutorwindow.h index 9adbcc32..44fc29b5 100644 --- a/src/gui/slrtutorwindow.h +++ b/src/gui/slrtutorwindow.h @@ -20,6 +20,8 @@ #define SLRTUTORWINDOW_H #include "UniqueQueue.h" +#include "automatonview.h" +#include "automatonviewerdialog.h" #include "examsession.h" #include "grammar.hpp" #include "grammarview.h" @@ -228,6 +230,36 @@ class SLRTutorWindow : public QWidget { bool confirmExitToHome(); QString promptExportFilePath() const; + // ====== LR(0) Automaton Viewer ================================ + /** + * @brief Builds the (hidden) automaton view and prepares the button. + * + * Does nothing in exam mode: the automaton would reveal the collection. + * The view is not embedded in the main layout; it lives in a separate + * floating viewer opened on demand. + */ + void setupAutomatonPanel(); + + /** + * @brief Syncs the automaton view with the current tutor state. + * + * Highlights the state under analysis during the C/CA/CB loop, switches + * to full consultation mode from D onwards, and marks conflict (F/FA) + * and reduce (G) states. Updates apply whether or not the viewer is open. + */ + void updateAutomatonPanel(); + + /** + * @brief Opens the floating automaton viewer, or raises it if already + * open. + */ + void openAutomatonViewer(); + + /** + * @brief Enables the viewer button once at least one state (I0) exists. + */ + void updateAutomatonButton(); + // ====== Exam Mode ============================================= void postQuestion(); ///< Shows and remembers the next question. QString examSolutionText(); ///< Expected answer for the current state. @@ -318,6 +350,11 @@ class SLRTutorWindow : public QWidget { QVector statesWithLr0Conflict; // Populated in F std::queue conflictStatesIdQueue; unsigned currentConflictStateId = 0; + + // ====== LR(0) Automaton Viewer ================================= + AutomatonView* automatonView = nullptr; // null in exam mode + AutomatonViewerDialog* automatonViewer = nullptr; // null when closed + QSet reduceStateIds; // States highlighted in G state currentConflictState; std::queue diff --git a/src/gui/slrtutorwindow.ui b/src/gui/slrtutorwindow.ui index 46d3da4c..261f3860 100644 --- a/src/gui/slrtutorwindow.ui +++ b/src/gui/slrtutorwindow.ui @@ -176,11 +176,34 @@ - - - true + + + 8 - + + + + PointingHandCursor + + + Ver autómata LR(0) + + + + + + + + 2 + 0 + + + + true + + + + diff --git a/src/widgets/automatonview.cpp b/src/widgets/automatonview.cpp new file mode 100644 index 00000000..52594193 --- /dev/null +++ b/src/widgets/automatonview.cpp @@ -0,0 +1,531 @@ +/* + * SyntaxTutor - Interactive Tutorial About Syntax Analyzers + * Copyright (C) 2025 Jose R. (jose-rzm) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "automatonview.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr qreal kNodeRadius = 24.0; +constexpr qreal kLevelSpacing = 150.0; +constexpr qreal kRowSpacing = 96.0; +constexpr qreal kArrowLength = 11.0; +constexpr qreal kArrowWidth = 8.0; +constexpr double kMinZoom = 0.4; +constexpr double kMaxZoom = 2.5; + +const QColor kNodeFill(0x2B, 0x31, 0x33); +const QColor kNodeBorder(0x3A, 0x41, 0x44); +const QColor kNodeText(0xF1, 0xF4, 0xF5); +const QColor kCurrentAccent(0x11, 0xB3, 0xBC); +const QColor kConflictAccent(0xD9, 0x53, 0x4F); +const QColor kReduceAccent(0xE5, 0xB5, 0x67); +const QColor kEdgeColor(0x5F, 0x6C, 0x70); +const QColor kEdgeLabelColor(0x9F, 0xCF, 0xD2); +const QColor kPlaceholderColor(0x6F, 0x7A, 0x7E); +} // namespace + +AutomatonView::AutomatonView(QWidget* parent) : QGraphicsView(parent) { + setObjectName("slrAutomatonView"); + scene_ = new QGraphicsScene(this); + setScene(scene_); + setRenderHint(QPainter::Antialiasing, true); + setRenderHint(QPainter::TextAntialiasing, true); + setDragMode(QGraphicsView::ScrollHandDrag); + setTransformationAnchor(QGraphicsView::AnchorUnderMouse); + viewport()->setCursor(Qt::ArrowCursor); +} + +QHash AutomatonView::computeLevels( + const QVector& transitions) const { + QHash> adjacency; + for (const AutomatonTransitionInfo& transition : transitions) { + adjacency[transition.from].insert(transition.to); + } + + QHash levels; + if (nodes_.contains(0)) { + levels.insert(0, 0); + QQueue pending; + pending.enqueue(0); + while (!pending.isEmpty()) { + const unsigned from = pending.dequeue(); + QList next = adjacency.value(from).values(); + std::sort(next.begin(), next.end()); + for (unsigned to : next) { + if (!levels.contains(to)) { + levels.insert(to, levels.value(from) + 1); + pending.enqueue(to); + } + } + } + } + + // Unreachable states (should not happen) go to one extra column. + int maxLevel = 0; + for (int level : levels) { + maxLevel = qMax(maxLevel, level); + } + for (auto it = nodes_.cbegin(); it != nodes_.cend(); ++it) { + if (!levels.contains(it.key())) { + levels.insert(it.key(), maxLevel + 1); + } + } + return levels; +} + +void AutomatonView::setAutomaton( + const QVector& states, + const QVector& transitions) { + scene_->clear(); + nodes_.clear(); + edges_.clear(); + conflictIds_.clear(); + reduceIds_.clear(); + currentId_ = -1; + fullyRevealed_ = false; + + for (const AutomatonStateInfo& info : states) { + Node node; + node.id = info.id; + nodes_.insert(info.id, node); + } + + // Deterministic BFS-level layout: columns are BFS levels from I0 and + // rows follow ascending state ids, so the picture is stable no matter + // in which order states get revealed. + const QHash levels = computeLevels(transitions); + std::map> byLevel; + for (auto it = levels.cbegin(); it != levels.cend(); ++it) { + byLevel[it.value()].append(it.key()); + } + + QFont labelFont = QFontDatabase::systemFont(QFontDatabase::GeneralFont); + labelFont.setPointSize(12); + labelFont.setBold(true); + + for (auto& [level, ids] : byLevel) { + std::sort(ids.begin(), ids.end()); + const qreal columnHeight = (ids.size() - 1) * kRowSpacing; + for (int row = 0; row < ids.size(); ++row) { + Node& node = nodes_[ids.at(row)]; + node.center = QPointF(level * kLevelSpacing, + row * kRowSpacing - columnHeight / 2.0); + } + } + + for (const AutomatonStateInfo& info : states) { + Node& node = nodes_[info.id]; + + node.circle = scene_->addEllipse( + QRectF(node.center.x() - kNodeRadius, node.center.y() - kNodeRadius, + kNodeRadius * 2, kNodeRadius * 2)); + node.circle->setZValue(1); + node.circle->setToolTip(info.itemsText); + + node.label = scene_->addSimpleText(QString("I%1").arg(info.id)); + node.label->setFont(labelFont); + node.label->setBrush(kNodeText); + const QRectF labelBounds = node.label->boundingRect(); + node.label->setPos(node.center.x() - labelBounds.width() / 2.0, + node.center.y() - labelBounds.height() / 2.0); + node.label->setZValue(2); + node.label->setToolTip(info.itemsText); + + applyNodeStyle(node); + setNodeVisible(node, false); + } + + // Combine transitions that share (from, to) into one labelled edge. + std::map, QStringList> combined; + for (const AutomatonTransitionInfo& transition : transitions) { + combined[{transition.from, transition.to}].append(transition.symbol); + } + QSet> pairs; + for (const auto& [key, symbols] : combined) { + pairs.insert({key.first, key.second}); + } + + for (const auto& [key, symbols] : combined) { + if (!nodes_.contains(key.first) || !nodes_.contains(key.second)) { + continue; + } + Edge edge; + edge.from = key.first; + edge.to = key.second; + edge.symbols = symbols; + edge.symbols.sort(); + buildEdgeGeometry(edge); + setEdgeVisible(edge, false); + edges_.append(edge); + } + + const QRectF bounds = scene_->itemsBoundingRect(); + scene_->setSceneRect(bounds.adjusted(-50, -50, 50, 50)); + + placeholder_ = scene_->addText( + tr("El autómata aparecerá cuando\nconstruyas el estado inicial I0.")); + placeholder_->setDefaultTextColor(kPlaceholderColor); + QFont placeholderFont = placeholder_->font(); + placeholderFont.setPointSize(12); + placeholder_->setFont(placeholderFont); + const QRectF placeholderBounds = placeholder_->boundingRect(); + placeholder_->setPos(-placeholderBounds.width() / 2.0, + -placeholderBounds.height() / 2.0); + placeholder_->setZValue(3); + + resetTransform(); + zoom_ = 1.0; + centerOn(placeholder_); +} + +void AutomatonView::buildEdgeGeometry(Edge& edge) { + const Node& fromNode = nodes_[edge.from]; + const Node& toNode = nodes_[edge.to]; + + QPainterPath path; + QPointF arrowTip; + QPointF arrowDirection; + QPointF labelPos; + + if (edge.from == edge.to) { + // Self loop drawn above the node. + const QPointF c = fromNode.center; + const QPointF start = c + QPointF(-12.0, -kNodeRadius + 6.0); + const QPointF end = c + QPointF(14.0, -kNodeRadius + 2.0); + path.moveTo(start); + path.cubicTo(c + QPointF(-34.0, -kNodeRadius - 44.0), + c + QPointF(34.0, -kNodeRadius - 44.0), end); + arrowTip = end; + arrowDirection = QPointF(0.25, 1.0); + labelPos = c + QPointF(0.0, -kNodeRadius - 48.0); + } else { + const QPointF delta = toNode.center - fromNode.center; + const qreal length = std::hypot(delta.x(), delta.y()); + const QPointF unit = length > 0 ? delta / length : QPointF(1, 0); + const QPointF normal(-unit.y(), unit.x()); + + // Bend long or backward edges so they do not cross nodes placed in + // between; a reverse edge bends to the other side automatically. + const bool backward = edge.to <= edge.from; + qreal bend = 0.0; + if (backward || length > kLevelSpacing * 1.4) { + bend = backward ? -34.0 : 34.0; + } + + const QPointF start = fromNode.center + unit * kNodeRadius; + const QPointF end = + toNode.center - unit * (kNodeRadius + kArrowLength * 0.6); + const QPointF control = (start + end) / 2.0 + normal * (bend * 2.0); + + path.moveTo(start); + path.quadTo(control, end); + + arrowTip = end + unit * (kArrowLength * 0.6); + arrowDirection = arrowTip - control; + labelPos = + path.pointAtPercent(0.5) + normal * (bend >= 0 ? 12.0 : -16.0); + } + + QPen edgePen(kEdgeColor, 1.6); + edgePen.setCapStyle(Qt::RoundCap); + edge.path = scene_->addPath(path, edgePen); + edge.path->setZValue(0); + + const qreal dirLength = std::hypot(arrowDirection.x(), arrowDirection.y()); + const QPointF unitDir = + dirLength > 0 ? arrowDirection / dirLength : QPointF(1, 0); + const QPointF normalDir(-unitDir.y(), unitDir.x()); + QPolygonF arrowHead; + arrowHead << arrowTip + << arrowTip - unitDir * kArrowLength + + normalDir * (kArrowWidth / 2.0) + << arrowTip - unitDir * kArrowLength - + normalDir * (kArrowWidth / 2.0); + edge.arrow = scene_->addPolygon(arrowHead, Qt::NoPen, kEdgeColor); + edge.arrow->setZValue(0); + + QFont labelFont = QFontDatabase::systemFont(QFontDatabase::GeneralFont); + labelFont.setPointSize(10); + edge.label = scene_->addSimpleText(edge.symbols.join(", ")); + edge.label->setFont(labelFont); + edge.label->setBrush(kEdgeLabelColor); + const QRectF labelBounds = edge.label->boundingRect(); + edge.label->setPos(labelPos.x() - labelBounds.width() / 2.0, + labelPos.y() - labelBounds.height() / 2.0); + edge.label->setZValue(2); +} + +void AutomatonView::applyNodeStyle(Node& node) { + QBrush brush(kNodeFill); + QPen pen(kNodeBorder, 1.6); + + if (conflictIds_.contains(node.id)) { + QColor tint = kConflictAccent; + tint.setAlpha(46); + brush = QBrush(tint); + pen = QPen(kConflictAccent, 2.0); + } else if (reduceIds_.contains(node.id)) { + QColor tint = kReduceAccent; + tint.setAlpha(38); + brush = QBrush(tint); + pen = QPen(kReduceAccent, 2.0); + } + if (currentId_ >= 0 && node.id == static_cast(currentId_)) { + pen = QPen(kCurrentAccent, 3.0); + } + + if (node.circle != nullptr) { + node.circle->setBrush(brush); + node.circle->setPen(pen); + } +} + +void AutomatonView::setNodeVisible(Node& node, bool visible) { + node.revealed = visible; + if (node.circle != nullptr) { + node.circle->setVisible(visible); + } + if (node.label != nullptr) { + node.label->setVisible(visible); + } +} + +void AutomatonView::setEdgeVisible(Edge& edge, bool visible) { + edge.revealed = visible; + if (edge.path != nullptr) { + edge.path->setVisible(visible); + } + if (edge.arrow != nullptr) { + edge.arrow->setVisible(visible); + } + if (edge.label != nullptr) { + edge.label->setVisible(visible); + } +} + +void AutomatonView::updatePlaceholder() { + if (placeholder_ != nullptr) { + placeholder_->setVisible(visibleStateCount() == 0); + } +} + +void AutomatonView::revealState(unsigned id) { + auto it = nodes_.find(id); + if (it == nodes_.end() || it->revealed) { + return; + } + setNodeVisible(*it, true); + updatePlaceholder(); + if (it->circle != nullptr) { + ensureVisible(it->circle, 60, 60); + } +} + +void AutomatonView::revealTransition(unsigned from, unsigned to) { + revealState(from); + revealState(to); + for (Edge& edge : edges_) { + if (edge.from == from && edge.to == to && !edge.revealed) { + setEdgeVisible(edge, true); + } + } +} + +void AutomatonView::revealAll() { + if (fullyRevealed_) { + return; + } + fullyRevealed_ = true; + for (Node& node : nodes_) { + setNodeVisible(node, true); + } + for (Edge& edge : edges_) { + setEdgeVisible(edge, true); + } + updatePlaceholder(); + fitToView(); +} + +void AutomatonView::zoomIn() { + const double target = qBound(kMinZoom, zoom_ * 1.15, kMaxZoom); + if (!qFuzzyCompare(target, zoom_)) { + scale(target / zoom_, target / zoom_); + zoom_ = target; + } +} + +void AutomatonView::zoomOut() { + const double target = qBound(kMinZoom, zoom_ / 1.15, kMaxZoom); + if (!qFuzzyCompare(target, zoom_)) { + scale(target / zoom_, target / zoom_); + zoom_ = target; + } +} + +void AutomatonView::fitToView() { + // Fit the revealed content, never zooming in past 1:1. + resetTransform(); + zoom_ = 1.0; + + QRectF bounds; + for (const Node& node : nodes_) { + if (node.revealed && node.circle != nullptr) { + bounds = bounds.united(node.circle->sceneBoundingRect()); + } + } + if (bounds.isEmpty()) { + bounds = scene_->sceneRect(); + } else { + bounds.adjust(-40, -40, 40, 40); + } + + if (!bounds.isEmpty() && viewport() != nullptr) { + const qreal scaleX = viewport()->width() / bounds.width(); + const qreal scaleY = viewport()->height() / bounds.height(); + const qreal factor = qMin(1.0, qMin(scaleX, scaleY)); + if (factor < 1.0 && factor > 0.0) { + scale(factor, factor); + zoom_ = factor; + } + } + centerOn(bounds.center()); +} + +void AutomatonView::centerOnCurrentState() { + if (currentId_ < 0) { + return; + } + auto it = nodes_.find(static_cast(currentId_)); + if (it != nodes_.end() && it->circle != nullptr) { + if (!it->revealed) { + revealState(static_cast(currentId_)); + } + centerOn(it->circle); + } +} + +void AutomatonView::setCurrentState(int id) { + if (currentId_ == id) { + return; + } + currentId_ = id; + for (Node& node : nodes_) { + applyNodeStyle(node); + } + if (id >= 0) { + auto it = nodes_.find(static_cast(id)); + if (it != nodes_.end() && it->revealed && it->circle != nullptr) { + ensureVisible(it->circle, 60, 60); + } + } +} + +void AutomatonView::setConflictStates(const QSet& ids) { + conflictIds_ = ids; + for (Node& node : nodes_) { + applyNodeStyle(node); + } +} + +void AutomatonView::setReduceStates(const QSet& ids) { + reduceIds_ = ids; + for (Node& node : nodes_) { + applyNodeStyle(node); + } +} + +void AutomatonView::clearStateMarks() { + if (conflictIds_.isEmpty() && reduceIds_.isEmpty()) { + return; + } + conflictIds_.clear(); + reduceIds_.clear(); + for (Node& node : nodes_) { + applyNodeStyle(node); + } +} + +bool AutomatonView::isStateVisible(unsigned id) const { + auto it = nodes_.constFind(id); + return it != nodes_.constEnd() && it->revealed; +} + +int AutomatonView::visibleStateCount() const { + int count = 0; + for (const Node& node : nodes_) { + if (node.revealed) { + ++count; + } + } + return count; +} + +int AutomatonView::visibleTransitionCount() const { + int count = 0; + for (const Edge& edge : edges_) { + if (edge.revealed) { + ++count; + } + } + return count; +} + +bool AutomatonView::isFullyRevealed() const { + if (fullyRevealed_) { + return true; + } + return visibleStateCount() == totalStateCount() && + visibleTransitionCount() == static_cast(edges_.size()); +} + +void AutomatonView::wheelEvent(QWheelEvent* event) { + if (event->modifiers().testFlag(Qt::ControlModifier)) { + const double factor = event->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; + const double target = qBound(kMinZoom, zoom_ * factor, kMaxZoom); + if (!qFuzzyCompare(target, zoom_)) { + scale(target / zoom_, target / zoom_); + zoom_ = target; + } + event->accept(); + return; + } + QGraphicsView::wheelEvent(event); +} + +void AutomatonView::mousePressEvent(QMouseEvent* event) { + if (QGraphicsItem* item = itemAt(event->pos()); + item != nullptr && !item->toolTip().isEmpty()) { + QToolTip::showText(event->globalPosition().toPoint(), item->toolTip(), + this); + } + QGraphicsView::mousePressEvent(event); +} diff --git a/src/widgets/automatonview.h b/src/widgets/automatonview.h new file mode 100644 index 00000000..2294994d --- /dev/null +++ b/src/widgets/automatonview.h @@ -0,0 +1,172 @@ +/* + * 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 AUTOMATONVIEW_H +#define AUTOMATONVIEW_H + +#include +#include +#include +#include + +class QGraphicsEllipseItem; +class QGraphicsPathItem; +class QGraphicsPolygonItem; +class QGraphicsSimpleTextItem; +class QGraphicsTextItem; + +/** + * @struct AutomatonStateInfo + * @brief One LR(0) state of the automaton to display. + */ +struct AutomatonStateInfo { + /// @brief State id (the N in "IN"). + unsigned id; + /// @brief Items of the state, shown when hovering or clicking the node. + QString itemsText; +}; + +/** + * @struct AutomatonTransitionInfo + * @brief One delta(I, X) transition of the automaton to display. + */ +struct AutomatonTransitionInfo { + unsigned from; + QString symbol; + unsigned to; +}; + +/** + * @class AutomatonView + * @brief Progressive LR(0) automaton visualization. + * + * QGraphicsView-based panel that renders the LR(0) collection as a graph of + * state nodes (I0, I1, ...) and labelled transition edges. The full + * automaton is loaded up front with a deterministic BFS-level layout, but + * every node and edge starts hidden: the SLR tutor reveals them as the + * student constructs them, so the panel never spoils the construction. + * Once the collection is complete the tutor calls revealAll() and the view + * becomes a stable consultation tool, with optional highlights for the + * current, conflict and reduce states. + * + * Hovering or clicking a node shows the LR(0) items of that state. The view + * supports Ctrl+wheel zooming and drag scrolling. + */ +class AutomatonView : public QGraphicsView { + Q_OBJECT + public: + explicit AutomatonView(QWidget* parent = nullptr); + + /** + * @brief Loads the automaton and computes the layout. Everything starts + * hidden. + * + * @param states All LR(0) states. + * @param transitions All delta transitions. + */ + void setAutomaton(const QVector& states, + const QVector& transitions); + + /// @brief Makes a state node visible. + void revealState(unsigned id); + + /** + * @brief Makes a transition edge visible, together with both endpoint + * states. + */ + void revealTransition(unsigned from, unsigned to); + + /// @brief Reveals every state and transition (consultation mode). + void revealAll(); + + /// @brief Highlights one state as the one being analyzed (-1 for none). + void setCurrentState(int id); + + /// @brief Marks states that contain an LR(0) conflict. + void setConflictStates(const QSet& ids); + + /// @brief Marks states that can perform a reduction. + void setReduceStates(const QSet& ids); + + /// @brief Clears conflict and reduce marks. + void clearStateMarks(); + + /// @brief Zooms in one step around the view center. + void zoomIn(); + + /// @brief Zooms out one step around the view center. + void zoomOut(); + + /// @brief Fits all revealed content within the viewport. + void fitToView(); + + /// @brief Centers (and reveals if needed) the currently inspected state. + void centerOnCurrentState(); + + bool isStateVisible(unsigned id) const; + int visibleStateCount() const; + int totalStateCount() const { return static_cast(nodes_.size()); } + int visibleTransitionCount() const; + bool isFullyRevealed() const; + int currentStateId() const { return currentId_; } + QSet conflictStates() const { return conflictIds_; } + QSet reduceStates() const { return reduceIds_; } + + protected: + void wheelEvent(QWheelEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + + private: + struct Node { + unsigned id = 0; + QPointF center; + QGraphicsEllipseItem* circle = nullptr; + QGraphicsSimpleTextItem* label = nullptr; + bool revealed = false; + }; + + struct Edge { + unsigned from = 0; + unsigned to = 0; + QStringList symbols; + QGraphicsPathItem* path = nullptr; + QGraphicsPolygonItem* arrow = nullptr; + QGraphicsSimpleTextItem* label = nullptr; + bool revealed = false; + }; + + QHash + computeLevels(const QVector& transitions) const; + void buildEdgeGeometry(Edge& edge); + void applyNodeStyle(Node& node); + void setNodeVisible(Node& node, bool visible); + void setEdgeVisible(Edge& edge, bool visible); + void updatePlaceholder(); + + QGraphicsScene* scene_ = nullptr; + QGraphicsTextItem* placeholder_ = nullptr; + QHash nodes_; + QList edges_; + QSet conflictIds_; + QSet reduceIds_; + int currentId_ = -1; + bool fullyRevealed_ = false; + double zoom_ = 1.0; +}; + +#endif // AUTOMATONVIEW_H diff --git a/tests/tests.pro b/tests/tests.pro index b8e4f1ff..f79b803e 100644 --- a/tests/tests.pro +++ b/tests/tests.pro @@ -30,9 +30,11 @@ SOURCES += \ ../src/backend/lr0_item.cpp \ ../src/backend/slr1_parser.cpp \ ../src/backend/symbol_table.cpp \ + ../src/widgets/automatonview.cpp \ ../src/widgets/customtextedit.cpp \ ../src/widgets/grammarview.cpp \ ../src/widgets/tutorialmanager.cpp \ + ../src/gui/automatonviewerdialog.cpp \ ../src/gui/examreportdialog.cpp \ ../src/gui/grammareditordialog.cpp \ ../src/gui/lltabledialog.cpp \ @@ -55,9 +57,11 @@ HEADERS += \ ../src/backend/slr1_parser.hpp \ ../src/backend/state.hpp \ ../src/backend/symbol_table.hpp \ + ../src/widgets/automatonview.h \ ../src/widgets/customtextedit.h \ ../src/widgets/grammarview.h \ ../src/widgets/tutorialmanager.h \ + ../src/gui/automatonviewerdialog.h \ ../src/gui/examreportdialog.h \ ../src/gui/examsession.h \ ../src/gui/grammareditordialog.h \ diff --git a/tests/tutor/slr_tutor_window_test.cpp b/tests/tutor/slr_tutor_window_test.cpp index 3f06642e..c9b96891 100644 --- a/tests/tutor/slr_tutor_window_test.cpp +++ b/tests/tutor/slr_tutor_window_test.cpp @@ -1,5 +1,7 @@ #include "tutor_window_test.h" +#include "automatonview.h" +#include "automatonviewerdialog.h" #include "examreportdialog.h" #include "qt_modal_test_utils.h" #include "slr_tutor_test_utils.h" @@ -14,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -1105,3 +1108,323 @@ void TutorWindowTest::slrExamModeWrongAnswersFollowMainPathAndShowReport() { QVERIFY(closeButton != nullptr); QTest::mouseClick(closeButton, Qt::LeftButton); } + +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-22 +// Summary: +// Verifies the LR(0) automaton is fully disabled in exam mode: no view, no +// button, and the viewer cannot be opened. +// +// Situation: +// SLR(1) tutor created in exam mode, compared against a normal tutor. +// +// Action: +// The test inspects the automaton button and tries to open the viewer. +// +// Expected: +// In exam mode no AutomatonView exists, the button is hidden, and no +// viewer dialog appears; in normal mode the button exists. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAutomatonHiddenInExamMode() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + SLRTutorWindow tutor(grammar, nullptr, nullptr, true); + + QVERIFY(tutor.findChild() == nullptr); + auto* button = tutor.findChild("automatonButton"); + QVERIFY(button != nullptr); + QVERIFY(button->isHidden()); + + tutor.openAutomatonViewer(); + QTest::qWait(20); + QVERIFY(QtModalTestUtils::findVisibleTopLevelWidget() == + nullptr); + + SLRTutorWindow normalTutor(grammar, nullptr); + QVERIFY(normalTutor.findChild() != nullptr); + auto* normalButton = + normalTutor.findChild("automatonButton"); + QVERIFY(normalButton != nullptr); + QVERIFY(!normalButton->isHidden()); +} + +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-23 +// Summary: +// Verifies I0 only appears in the automaton after the student constructs +// it, both on the direct path (A correct) and the fallback path (A'). +// +// Situation: +// Two SLR(1) tutors on the simple fixture. +// +// Action: +// One tutor answers A correctly; the other fails A and walks the +// A1..A4 -> A' fallback. +// +// Expected: +// The automaton is empty before the construction and shows exactly I0 +// afterwards in both paths. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAutomatonRevealsI0AfterInitialConstruction() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + + { + SLRTutorWindow tutor(grammar, nullptr); + auto* view = tutor.findChild(); + QVERIFY(view != nullptr); + QCOMPARE(view->visibleStateCount(), 0); + + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + QCOMPARE(view->visibleStateCount(), 1); + QVERIFY(view->isStateVisible(0)); + } + + { + SLRTutorWindow tutor(grammar, nullptr); + auto* view = tutor.findChild(); + QVERIFY(view != nullptr); + + tutor.setAnswerForTest(QStringLiteral("zz")); + tutor.submitForTest(); + QCOMPARE(tutor.currentStateForTest(), QString("A1")); + QCOMPARE(view->visibleStateCount(), 0); + + int guard = 0; + while (tutor.currentStateForTest() != "B" && ++guard < 20) { + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + QVERIFY(tutor.currentStateForTest() == "B" || + view->visibleStateCount() == 0); + } + QCOMPARE(tutor.currentStateForTest(), QString("B")); + QCOMPARE(view->visibleStateCount(), 1); + QVERIFY(view->isStateVisible(0)); + } +} + +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-24 +// Summary: +// Verifies progressive reveal: during the LR(0) loop only constructed +// states/transitions are visible, transitions appear on correct CB +// answers, and reaching D switches to the fully revealed automaton. +// +// Situation: +// SLR(1) tutor on the simple fixture driven with correct answers. +// +// Action: +// The test walks the whole B -> C -> CA -> CB loop, sampling the +// automaton before and after each CB answer. +// +// Expected: +// Before the first CB answer no transition is visible and the automaton +// is not fully revealed; transitions appear with CB answers and the +// current state stays highlighted during the loop; at D everything is +// visible. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAutomatonProgressivelyRevealsAndCompletesAfterLoop() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + SLRTutorWindow tutor(grammar, nullptr); + auto* view = tutor.findChild(); + QVERIFY(view != nullptr); + QVERIFY(view->totalStateCount() > 1); + + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + QCOMPARE(view->visibleTransitionCount(), 0); + + bool sawTransitionReveal = false; + bool sawPartialDuringLoop = false; + int guard = 0; + while (tutor.currentStateForTest() != "D" && ++guard < 200) { + const QString state = tutor.currentStateForTest(); + if (state == "C" || state == "CA" || state == "CB") { + QCOMPARE(view->currentStateId(), + static_cast(tutor.currentStateIdForTest())); + } + if (!view->isFullyRevealed()) { + sawPartialDuringLoop = true; + } + + const int transitionsBefore = view->visibleTransitionCount(); + const bool nonEpsilonCb = + state == "CB" && tutor.currentCbSymbolForTest() != "EPSILON"; + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + + if (nonEpsilonCb && + view->visibleTransitionCount() > transitionsBefore) { + sawTransitionReveal = true; + } + } + QCOMPARE(tutor.currentStateForTest(), QString("D")); + QVERIFY(sawPartialDuringLoop); + QVERIFY(sawTransitionReveal); + + // Full consultation mode after the loop. + QVERIFY(view->isFullyRevealed()); + QCOMPARE(view->visibleStateCount(), view->totalStateCount()); + QCOMPARE(view->currentStateId(), -1); +} + +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-25 +// Summary: +// Verifies the consultation-mode highlights: conflict states in F/FA and +// reduce states in G. +// +// Situation: +// SLR(1) tutors on the conflict fixture (for F/FA) and the simple +// fixture (for G). +// +// Action: +// The test drives each tutor to F and G respectively. +// +// Expected: +// In F the conflict states are marked; in G the reduce-capable states +// are marked and the state under analysis is the current one. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAutomatonHighlightsConflictAndReduceStates() { + { + const Grammar grammar = TutorGrammarFixtures::makeSlrConflictGrammar(); + SLRTutorWindow tutor(grammar, nullptr); + auto* view = tutor.findChild(); + QVERIFY(view != nullptr); + + SlrTutorTestUtils::driveTutorToState(tutor, "F"); + QVERIFY(view->isFullyRevealed()); + QVERIFY(!view->conflictStates().isEmpty()); + QCOMPARE(view->conflictStates(), tutor.solutionForF()); + } + + { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + SLRTutorWindow tutor(grammar, nullptr); + auto* view = tutor.findChild(); + QVERIFY(view != nullptr); + + SlrTutorTestUtils::driveTutorToState(tutor, "G"); + QVERIFY(view->isFullyRevealed()); + QVERIFY(view->conflictStates().isEmpty()); + QVERIFY(!view->reduceStates().isEmpty()); + QVERIFY(view->reduceStates().contains( + static_cast(view->currentStateId()))); + } +} + +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-26 +// Summary: +// Verifies the automaton button gating and that the viewer is reused, not +// duplicated. +// +// Situation: +// SLR(1) tutor on the simple fixture. +// +// Action: +// The test checks the button before and after I0 is built, opens the +// viewer twice, and closes/reopens it. +// +// Expected: +// The button is disabled before I0 and enabled after; clicking twice keeps +// exactly one viewer; closing then reopening yields a fresh single viewer. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAutomatonButtonGatingAndViewerReuse() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + SLRTutorWindow tutor(grammar, nullptr); + + auto* button = tutor.findChild("automatonButton"); + QVERIFY(button != nullptr); + QVERIFY(!button->isHidden()); + QVERIFY(!button->isEnabled()); // no I0 yet + + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + QCOMPARE(tutor.currentStateForTest(), QString("B")); + QVERIFY(button->isEnabled()); // I0 constructed + + auto countViewers = []() { + int count = 0; + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (qobject_cast(widget) != nullptr && + widget->isVisible()) { + ++count; + } + } + return count; + }; + + QTest::mouseClick(button, Qt::LeftButton); + auto* viewer = + QtModalTestUtils::waitForVisibleTopLevelWidget(); + QVERIFY(viewer != nullptr); + QCOMPARE(countViewers(), 1); + // The view moved into the viewer. + QVERIFY(viewer->findChild() != nullptr); + + // Clicking again must not spawn a second viewer. + QTest::mouseClick(button, Qt::LeftButton); + QTest::qWait(20); + QCOMPARE(countViewers(), 1); + + // Close and reopen: exactly one viewer again, view reclaimed in between. + QPointer viewerGuard(viewer); + viewer->close(); + QTRY_VERIFY(viewerGuard == nullptr || !viewerGuard->isVisible()); + QTRY_COMPARE(countViewers(), 0); + QVERIFY(tutor.findChild() != nullptr); // reclaimed + + QTest::mouseClick(button, Qt::LeftButton); + auto* reopened = + QtModalTestUtils::waitForVisibleTopLevelWidget(); + QVERIFY(reopened != nullptr); + QCOMPARE(countViewers(), 1); + reopened->close(); + QTRY_COMPARE(countViewers(), 0); +} + +// ----------------------------------------------------------------------------- +// Case: SLR1-TC-27 +// Summary: +// Verifies the viewer updates live while open and switches to full +// consultation mode once the LR(0) loop completes. +// +// Situation: +// SLR(1) tutor on the simple fixture with the viewer opened right after I0. +// +// Action: +// The test opens the viewer, then keeps answering correctly until D while +// sampling the embedded view. +// +// Expected: +// The embedded view reveals more states as the student progresses and is +// fully revealed once the tutor reaches D. +// ----------------------------------------------------------------------------- +void TutorWindowTest::slrAutomatonViewerUpdatesLiveAndReopens() { + const Grammar grammar = TutorGrammarFixtures::makeSlrSimpleGrammar(); + SLRTutorWindow tutor(grammar, nullptr); + + auto* button = tutor.findChild("automatonButton"); + QVERIFY(button != nullptr); + + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); // build I0 + QTest::mouseClick(button, Qt::LeftButton); + auto* viewer = + QtModalTestUtils::waitForVisibleTopLevelWidget(); + QVERIFY(viewer != nullptr); + + auto* view = viewer->findChild(); + QVERIFY(view != nullptr); + const int revealedAtOpen = view->visibleStateCount(); + QVERIFY(revealedAtOpen >= 1); + + int guard = 0; + while (tutor.currentStateForTest() != "D" && ++guard < 200) { + SlrTutorTestUtils::submitCorrectAnswerForCurrentState(tutor); + } + QCOMPARE(tutor.currentStateForTest(), QString("D")); + + // The same embedded view received the live updates. + QVERIFY(view->visibleStateCount() >= revealedAtOpen); + QVERIFY(view->isFullyRevealed()); + QCOMPARE(view->visibleStateCount(), view->totalStateCount()); + + viewer->close(); + QTest::qWait(20); +} diff --git a/tests/tutor/tutor_window_test.h b/tests/tutor/tutor_window_test.h index d8869cd1..f6036716 100644 --- a/tests/tutor/tutor_window_test.h +++ b/tests/tutor/tutor_window_test.h @@ -50,6 +50,12 @@ class TutorWindowTest : public QObject { void slrAcceptsFlexibleUserFormatting(); void slrAcceptsFlexibleTableCellFormatting(); void slrExamModeWrongAnswersFollowMainPathAndShowReport(); + void slrAutomatonHiddenInExamMode(); + void slrAutomatonRevealsI0AfterInitialConstruction(); + void slrAutomatonProgressivelyRevealsAndCompletesAfterLoop(); + void slrAutomatonHighlightsConflictAndReduceStates(); + void slrAutomatonButtonGatingAndViewerReuse(); + void slrAutomatonViewerUpdatesLiveAndReopens(); void mainInitialUiIsVisibleAndEnabled(); void mainSwitchLanguageToEnglishPersistsSelection();