Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
integration_test.cpp 17.88 KiB
#include "data/csv_dump.hpp"
#include "data/data_manager.hpp"
#include "data/graph.hpp"
#include "data/nodes/bonus_points.hpp"
#include "data/nodes/dynexite_exam.hpp"
#include "data/nodes/student_data.hpp"
#include "data/nodes/user_grade_config.hpp"
#include "data/nodes/user_point_config.hpp"
#include "file_io/helper.hpp"
#include "util/heuristics.hpp"
#include "util/strings.hpp"
#include "view/main_window/export_rwthonline.hpp"

#include <gtest/gtest.h>

#include <QRegularExpression>
#include <QSharedPointer>
#include <QString>

#include <algorithm>
#include <array>
#include <filesystem>
#include <regex>

// TODO
const auto fineOpenDynexite = "mostlyopennew";
const auto fineClosedDynexite = "mostlyclosednew";

constexpr auto nonregisteredOffset = 53;

enum FirstAttempts {
    NoFirstAttempts,
    LessThan50PercPoints,
    Between50And60,
    MoreThan60PercPoints,
    AllFirstAttempts
};

[[nodiscard]] QString getRwthOnlineFileName(const FirstAttempts firstAttempt) {
    switch (firstAttempt) {
    case NoFirstAttempts:
        return "nofirstnew";
    case LessThan50PercPoints:
        return "less50new";
    case Between50And60:
        return "50to60new";
    case MoreThan60PercPoints:
        return "more60new";
    case AllFirstAttempts:
        return "onlyfirstnew";
    }
    assert(false); // todo
    return "nofirstnew";
}

[[nodiscard]] Grade readGrade(QString s, bool *ok) {
    // Grades may be printed with a comma instead of a point for backwards
    // compatibility
    s.replace(',', '.');

    *ok = true;
    if (s == "1.0") {
        return Grade::grade1_0;
    } else if (s == "1.3") {
        return Grade::grade1_3;
    } else if (s == "1.7") {
        return Grade::grade1_7;
    } else if (s == "2.0") {
        return Grade::grade2_0;
    } else if (s == "2.3") {
        return Grade::grade2_3;
    } else if (s == "2.7") {
        return Grade::grade2_7;
    } else if (s == "3.0") {
        return Grade::grade3_0;
    } else if (s == "3.3") {
        return Grade::grade3_3;
    } else if (s == "3.7") {
        return Grade::grade3_7;
    } else if (s == "4.0") {
        return Grade::grade4_0;
    } else if (s == "5.0") {
        return Grade::grade5_0;
    } else {
        ADD_FAILURE() << "Unknown grade: " << s.toStdString();
        *ok = false;
        return Grade::NR_VALS;
    }
}

void testExportedFile(QTextStream &ts, const QString &pattern,
                      const bool useBonus,
                      const FirstAttempts firstAttemptConfig,
                      Points &avgFirstAttemptClosedPoints,
                      const GradingType fineGradingType,
                      const TaskEliminationType taskEliminationType,
                      const bool checkNonAttended,
                      const std::function<int(int)> &matriculationIncrementor) {

    QString line;
    const auto coarseGradingType = fineGradingType == GradingType::Closed
                                       ? GradingType::Open
                                       : GradingType::Closed;

    // Skip header
    ts.readLineInto(&line);

    // Attended students
    {
        using enum GradingType;
        SCOPED_TRACE("Full regex: " + pattern.toStdString());

        // Read export line by line
        auto matriculationCounter = matriculationIncrementor(0);

        // todo: maybe do differently
        const auto maxMatriculation = 53602;

        QRegularExpression re(pattern);
        while (matriculationCounter < maxMatriculation) {
            ts.readLineInto(&line);

            const auto match = re.match(line);
            SCOPED_TRACE("Line: " + line.toStdString());
            EXPECT_TRUE(match.hasMatch());

            QList<bool> oks;
            oks.reserve(8);

            // captures start at 1 (0 is the whole match)
            int captureCounter = 1;

            // Matriculation number
            const auto matriculationNr =
                match.captured(captureCounter++).toInt(&oks.emplace_back());

            // Combined grade
            EnumStorage<Grade> grade;
            grade[Combined] = readGrade(match.captured(captureCounter++),
                                        &oks.emplace_back());

            EnumStorage<Points, GradingType, 2> earnedPoints, maxPoints,
                bonusPoints;
            auto parsedAvgFirstAttemptClosedPoints = Points{0.};

            for (const auto t : {Closed, Open}) {
                // Remark
                // If we use bonus points, the position of found groups
                // differs depending on if this exam part was passed or not
                // (because the regex has a disjunction with capture groups
                // in the different branches)
                bool firstBranch = true;
                if (useBonus && match.captured(captureCounter).isEmpty()) {
                    captureCounter += 4;
                    firstBranch = false;
                }

                earnedPoints[t] = Points{match.captured(captureCounter++)
                                             .toDouble(&oks.emplace_back())};
                maxPoints[t] = Points{match.captured(captureCounter++)
                                          .toDouble(&oks.emplace_back())};
                if (useBonus) {
                    if (firstBranch) {
                        bonusPoints[t] =
                            Points{match.captured(captureCounter++)
                                       .toDouble(&oks.emplace_back())};
                        grade[t] = readGrade(match.captured(captureCounter++),
                                             &oks.emplace_back());

                        // Skip second branch
                        captureCounter += 4;
                    } else {
                        grade[t] = readGrade(match.captured(captureCounter++),
                                             &oks.emplace_back());
                        bonusPoints[t] =
                            Points{match.captured(captureCounter++)
                                       .toDouble(&oks.emplace_back())};
                    }
                } else {
                    grade[t] = readGrade(match.captured(captureCounter++),
                                         &oks.emplace_back());
                }

                // average
                if (t == Closed && firstAttemptConfig != NoFirstAttempts) {
                    parsedAvgFirstAttemptClosedPoints =
                        Points{match.captured(captureCounter++)
                                   .toDouble(&oks.emplace_back())};
                }
            }

            EXPECT_TRUE(std::ranges::all_of(oks, [](bool b) { return b; }))
                << "Some conversions failed.";

            EXPECT_EQ(matriculationNr, matriculationCounter);

            EnumStorage<Points, GradingType, 2> targetPoints;
            targetPoints[fineGradingType] = Points{
                (matriculationNr - 1) / 51 * 0.02 +
                (taskEliminationType == TaskEliminationType::creditFull ? 3.
                                                                        : 0.)};
            targetPoints[coarseGradingType] = Points{
                (matriculationNr - 1) % 51 * 0.2 +
                (taskEliminationType == TaskEliminationType::creditFull ? 3.
                                                                        : 0.)};

            EXPECT_DOUBLE_EQ(earnedPoints[fineGradingType].p,
                             targetPoints[fineGradingType].p);
            EXPECT_DOUBLE_EQ(earnedPoints[coarseGradingType].p,
                             targetPoints[coarseGradingType].p);
            EXPECT_EQ(maxPoints[fineGradingType],
                      Points{21. + (taskEliminationType ==
                                            TaskEliminationType::creditFull
                                        ? 3.
                                        : 0.)});
            EXPECT_EQ(maxPoints[coarseGradingType],
                      Points{10. + (taskEliminationType ==
                                            TaskEliminationType::creditFull
                                        ? 3.
                                        : 0.)});

            if (avgFirstAttemptClosedPoints < Points{0.}) {
                avgFirstAttemptClosedPoints = parsedAvgFirstAttemptClosedPoints;
            }
            EXPECT_DOUBLE_EQ(avgFirstAttemptClosedPoints.p,
                             parsedAvgFirstAttemptClosedPoints.p);

            matriculationCounter =
                matriculationIncrementor(matriculationCounter);
        }
    }

    if (checkNonAttended) {
        const auto notAttendedRegexString = "^\\d+;\\d;X;;;$";

        SCOPED_TRACE(std::string("Not attended regex: ") +
                     notAttendedRegexString);
        QRegularExpression reNotAttended(notAttendedRegexString);
        while (ts.readLineInto(&line)) {
            const auto match = reNotAttended.match(line);
            SCOPED_TRACE("Line: " + line.toStdString());
            ASSERT_TRUE(match.hasMatch());
        }
    }

    // TODO: also check exportManualAllStudents?
}

/*
 * Parameter meaning:
 * Parameter 1 (string): Name of the dynexite file
 * Parameter 2 (FirstAttempts): Number and points of first attempt students
 * Parameter 3 (GradeBoundaryStepSize): Step size of grade boundaries
 * Parameter 4 (bool): Whether or not the exam has a bonus file
 * Parameter 5 (TaskEliminationType): Elimination type of task 3
 */
class IntegrationFixture
    : public testing::TestWithParam<
          std::tuple<QString, FirstAttempts, GradeBoundaryStepSize, bool,
                     TaskEliminationType>> {};

INSTANTIATE_TEST_SUITE_P(
    IntegrationFixtureInstantiation, IntegrationFixture,
    testing::Combine(testing::Values(fineOpenDynexite, fineClosedDynexite),
                     testing::Values(NoFirstAttempts, LessThan50PercPoints,
                                     Between50And60, MoreThan60PercPoints,
                                     AllFirstAttempts),
                     testing::Values(GradeBoundaryStepSize::Min1_00,
                                     GradeBoundaryStepSize::Min0_50,
                                     GradeBoundaryStepSize::Min0_25),
                     testing::Bool(),
                     testing::Values(TaskEliminationType::withdrawTask,
                                     TaskEliminationType::creditFull)));

TEST_P(IntegrationFixture, myNameIntegrationTest) {

    const auto dynexiteFileName = std::get<0>(GetParam());
    const auto firstAttemptConfig = std::get<1>(GetParam());
    const auto gradeBoundaryStepSize = std::get<2>(GetParam());
    const auto useBonus = std::get<3>(GetParam());
    const auto taskEliminationType = std::get<4>(GetParam());

    const auto fineGradingType = dynexiteFileName == "mostlyopennew"
                                     ? GradingType::Open
                                     : GradingType::Closed;

    SCOPED_TRACE(
        "Reading in file " + dynexiteFileName.toStdString() +
        ", first attempt setting: " + std::to_string(firstAttemptConfig) +
        ", bonus points: " + std::to_string(useBonus));

    // Open the exam with the given files
    std::string file{__FILE__};
    std::filesystem::path rootPath{};
    if (file.find('/') != std::string::npos) {
        rootPath = std::filesystem::path({file.substr(0, file.rfind('/'))});
    } else {
        rootPath = std::filesystem::path({file.substr(0, file.rfind('\\'))});
    }

    std::cout << "Root path: " << rootPath.string() << std::endl;

    const auto dynexitePath =
        rootPath / "dynexite" / (dynexiteFileName.toStdString() + ".csv");
    const auto rwthOnlinePath =
        rootPath / "rwthonline" /
        (getRwthOnlineFileName(firstAttemptConfig).toStdString() + ".csv");
    const auto bonusPath = rootPath / "bonus" / "bonus.csv";

    ASSERT_TRUE(exists(dynexitePath))
        << "Found no file at " << dynexitePath.string()
        << ", root path: " << rootPath.string()
        << ", current path: " << std::filesystem::current_path().string();

    ASSERT_TRUE(exists(rwthOnlinePath))
        << "Found no file at " << dynexitePath.string()
        << ", root path: " << rootPath.string()
        << ", current path: " << std::filesystem::current_path().string();

    ASSERT_TRUE(!useBonus || exists(bonusPath))
        << "Found no file at " << dynexitePath.string()
        << ", root path: " << rootPath.string()
        << ", current path: " << std::filesystem::current_path().string();

    QSharedPointer<CsvDump> dynexiteDump;
    QSharedPointer<CsvDump> rwthDump;
    QSharedPointer<CsvDump> bonusDump;
    ASSERT_NO_FATAL_FAILURE(dynexiteDump = getDump(dynexitePath));
    ASSERT_NO_FATAL_FAILURE(rwthDump = getDump(rwthOnlinePath));
    if (useBonus) {
        ASSERT_NO_FATAL_FAILURE(bonusDump = getDump(bonusPath));
    } else {
        // Empty bonus point file
        bonusDump = QSharedPointer<CsvDump>::create();
    }

    DynexiteExam dynexite(*dynexiteDump);
    StudentData students(*rwthDump);
    BonusPoints bonus(*bonusDump);
    UserGradeConfig gc;
    UserPointConfig pc;
    pc.mGradeBoundaryStepSize = gradeBoundaryStepSize;

    const auto success =
        DataManager::loadNewExam(dynexite, students, bonus, gc, pc);
    ASSERT_TRUE(success);
    DataManager::makeNewCurrent();

    // Eliminate task with random points
    DataManager::taskEliminationChange(3, taskEliminationType);

    // TODO: change grade boundaries

    // REGEX BUILDING

    // The original strings contain characters which need to be escaped
    const auto escapedRemarkNoBonus =
        QString(str::fileExport::rwthOnlineRemarkNoBonus)
            .replace('(', "\\(")
            .replace(')', "\\)")
            .replace('.', "\\.");
    const auto escapedRemarkPassedBonus =
        QString(str::fileExport::rwthOnlineRemarkPassedBonus)
            .replace('(', "\\(")
            .replace(')', "\\)")
            .replace('.', "\\.")
            .replace('+', "\\+");
    const auto escapedRemarkNotPassedBonus =
        QString(str::fileExport::rwthOnlineRemarkNotPassedBonus)
            .replace('(', "\\(")
            .replace(')', "\\)")
            .replace('.', "\\.")
            .replace('+', "\\+");

    // Build up regex pattern for export lines (most importantly the remark)
    const auto decimalNumberPattern = QString("(\\d+\\.?\\d*)");
    // Grade inside the remark (written with a point)
    const auto gradePattern = QString("(\\d\\.\\d)");
    /*const auto linePattern =
        QString("^;;;;(\\d+);;;;;\\d;(\\d,\\d);%1 - %2;;;;;;;;;;;;;$");*/
    const auto linePattern = QString("^(\\d+);\\d;(\\d,\\d);%1 - %2;;$");
    const auto nonregisteredLinePattern =
        QString("^(\\d+);(\\d,\\d);%1 - %2$"); // todo
    const auto noBonusRemarkPattern =
        QString(escapedRemarkNoBonus)
            .arg("%20", decimalNumberPattern, gradePattern,
                 decimalNumberPattern);
    const auto passedBonusRemarkPattern =
        QString(escapedRemarkPassedBonus)
            .arg("%20", decimalNumberPattern, gradePattern,
                 decimalNumberPattern, decimalNumberPattern);
    const auto notPassedBonusRemarkPattern =
        QString(escapedRemarkNotPassedBonus)
            .arg("%20", decimalNumberPattern, gradePattern,
                 decimalNumberPattern, decimalNumberPattern);
    const auto firstAnyPattern =
        QString(str::fileExport::rwthOnlineRemarkAverageFirstAny)
            .arg(decimalNumberPattern);
    const auto firstNonePattern =
        QString(str::fileExport::rwthOnlineRemarkAverageFirstNone)
            .replace('.', "\\.");

    QString closedPattern, openPattern;

    if (useBonus) {
        if (firstAttemptConfig == NoFirstAttempts) {
            closedPattern =
                QString("(?:%1|%2)")
                    .arg(passedBonusRemarkPattern.arg("Closed"),
                         notPassedBonusRemarkPattern.arg("Closed")) +
                firstNonePattern;
        } else {
            closedPattern =
                QString("(?:%1|%2)")
                    .arg(passedBonusRemarkPattern.arg("Closed"),
                         notPassedBonusRemarkPattern.arg("Closed")) +
                firstAnyPattern;
        }
        openPattern = QString("(?:%1|%2)")
                          .arg(passedBonusRemarkPattern.arg("Open"),
                               notPassedBonusRemarkPattern.arg("Open"));
    } else {
        if (firstAttemptConfig == NoFirstAttempts) {
            closedPattern =
                QString("%1").arg(noBonusRemarkPattern.arg("Closed")) +
                firstNonePattern;
        } else {
            closedPattern =
                QString("%1").arg(noBonusRemarkPattern.arg("Closed")) +
                firstAnyPattern;
        }
        openPattern = QString("%1").arg(noBonusRemarkPattern.arg("Open"));
    }

    const auto fullPattern = linePattern.arg(closedPattern, openPattern);
    const auto fullNonregisteredPattern =
        nonregisteredLinePattern.arg(closedPattern, openPattern);

    Points avgFirstAttemptClosedPoints = Points{-1.};

    // ******************************* KNOWN STUDENTS //
    {
        QString out = "";
        QTextStream ts{&out};

        ASSERT_TRUE(exportKnownStudents(nullptr, &ts));

        // ******************************* //

        ASSERT_NO_FATAL_FAILURE(testExportedFile(
            ts, fullPattern, useBonus, firstAttemptConfig,
            avgFirstAttemptClosedPoints, fineGradingType, taskEliminationType,
            true, [](const int i) {
                return (i + 1) % nonregisteredOffset == 0 ? i + 2 : i + 1;
            }));
    }

    // ******************************* UNKNOWN STUDENTS //

    {
        QString out = "";
        QTextStream ts{&out};

        ASSERT_TRUE(exportUnknownStudents(nullptr, &ts));

        // ******************************* //

        ASSERT_NO_FATAL_FAILURE(testExportedFile(
            ts, fullNonregisteredPattern, useBonus, firstAttemptConfig,
            avgFirstAttemptClosedPoints, fineGradingType, taskEliminationType,
            false, [](const int i) { return i + nonregisteredOffset; }));
    }

    // TODO: check that avg is always the same and less than 50/between 50 and
    // 60/more than 60
}