Revision control

++ // Copyright (C) 2025 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ #include <QLibraryInfo>
++ #include <QProcess>
++ #include <QTest>
++
++ using namespace Qt::Literals::StringLiterals;
++
++ namespace {
++ class TempCopy
++ {
++ public:
++ TempCopy(const QString &fileName)
++ {
++ QFileInfo fi(fileName);
++ if (fi.isFile()) {
++ m_copyPath =
++ fi.absolutePath() + QDir::separator() + fi.baseName() + "_copy." + fi.suffix();
++ if (QFile::exists(m_copyPath))
++ QFile::remove(m_copyPath);
++ QFile::copy(fileName, m_copyPath);
++ } else {
++ m_copyPath = fi.absolutePath() + QDir::separator() + fi.fileName() + "_copy";
++ QDir dir(m_copyPath);
++ if (dir.exists())
++ dir.removeRecursively();
++ std::filesystem::copy(fileName.toStdString(), dir.filesystemPath(),
++ std::filesystem::copy_options::recursive);
++ }
++ }
++
++ const QString &path() const { return m_copyPath; }
++
++ ~TempCopy()
++ {
++ QFileInfo fi(m_copyPath);
++ if (fi.isFile())
++ QFile::remove(m_copyPath);
++ else
++ QDir(m_copyPath).removeRecursively();
++ }
++
++ private:
++ QString m_copyPath;
++ };
++
++ class FileGuard
++ {
++ public:
++ FileGuard(const QString &fileName) : m_fileName(fileName)
++ {
++ if (QFile::exists(m_fileName))
++ QFile::remove(m_fileName);
++ }
++ ~FileGuard()
++ {
++ if (QFile::exists(m_fileName))
++ QFile::remove(m_fileName);
++ }
++
++ private:
++ const QString m_fileName;
++ };
++
++ void verify(QProcess &proc, const QString &name)
++ {
++ QVERIFY2(proc.waitForStarted(), qPrintable(proc.errorString()));
++ QVERIFY2(proc.waitForFinished(10000), qPrintable(name + " hung"_L1));
++ QVERIFY2(proc.exitStatus() == QProcess::NormalExit, qPrintable(name + " crashed"));
++ QVERIFY2(proc.exitCode() == 0,
++ qPrintable(QString(name + " exited with status %1. Errors:\n%2")
++ .arg(proc.exitCode())
++ .arg(proc.readAllStandardError())));
++ }
++
++ void doCompare(const QString &actualOutput, const QString &expectedFn)
++ {
++ QFile file1(actualOutput);
++ QVERIFY(file1.open(QIODevice::ReadOnly | QIODevice::Text));
++ QList<QByteArray> actual = file1.readAll().split('\n');
++
++ QFile file2(expectedFn);
++ QVERIFY(file2.open(QIODevice::ReadOnly | QIODevice::Text));
++ QList<QByteArray> expected = file2.readAll().split('\n');
++
++ int i = 0, ei = expected.size(), gi = actual.size();
++ for (;; i++) {
++ if (i == gi) {
++ if (i == ei)
++ return;
++ gi = 0;
++ break;
++ } else if (i == ei) {
++ ei = 0;
++ break;
++ } else if (actual.at(i) != expected.at(i)) {
++ while ((ei - 1) >= i && (gi - 1) >= i && actual.at(gi - 1) == expected.at(ei - 1))
++ ei--, gi--;
++ break;
++ }
++ }
++ QByteArray diff;
++ for (int j = qMax(0, i - 3); j < i; j++)
++ diff += expected.at(j) + '\n';
++ diff += "<<<<<<< got\n";
++ for (int j = i; j < gi; j++) {
++ diff += actual.at(j) + '\n';
++ if (j >= i + 5) {
++ diff += "...\n";
++ break;
++ }
++ }
++ diff += "=========\n";
++ for (int j = i; j < ei; j++) {
++ diff += expected.at(j) + '\n';
++ if (j >= i + 5) {
++ diff += "...\n";
++ break;
++ }
++ }
++ diff += ">>>>>>> expected\n";
++ for (int j = ei; j < qMin(ei + 3, expected.size()); j++)
++ diff += expected.at(j) + '\n';
++ QFAIL(qPrintable("Output for " + expectedFn + " does not meet expectations:\n" + diff));
++ }
++
++ } // namespace
++
++ class tst_ltext2id : public QObject
++ {
++ Q_OBJECT
++
++ private slots:
++ void testTransform_data();
++ void testTransform();
++ void test2wayTransform_data();
++ void test2wayTransform();
++ void testTransformationError_data();
++ void testTransformationError();
++ void testProject_data();
++ void testProject();
++
++ private:
++ const QString m_basePath = QFINDTESTDATA("data/");
++ const QString m_ltext2id = QLibraryInfo::path(QLibraryInfo::BinariesPath) + "/ltext2id";
++ const QString m_lupdate = QLibraryInfo::path(QLibraryInfo::BinariesPath) + "/lupdate";
++ };
++
++ void tst_ltext2id::testTransform_data()
++ {
++ QTest::addColumn<QString>("inFileName");
++ QTest::addColumn<QString>("expectedFileName");
++
++ QTest::newRow("cpp file transform") << "all.cpp" << "all.cpp.out";
++ QTest::newRow("js file transform") << "main.js" << "main.js.out";
++ QTest::newRow("qml file transform") << "main.qml" << "main.qml.out";
++ QTest::newRow("ui file transform") << "project.ui" << "project.ui.out";
++ QTest::newRow("ts file transform") << "project.ts" << "project.ts.out";
++ }
++
++ void tst_ltext2id::testTransform()
++ {
++ QFETCH(QString, inFileName);
++ QFETCH(QString, expectedFileName);
++
++ TempCopy tc(m_basePath + inFileName);
++
++ QProcess ltext2id;
++ ltext2id.start(m_ltext2id, QStringList{ tc.path() }, QIODeviceBase::ReadOnly | QIODevice::Text);
++ verify(ltext2id, "ltext2id");
++
++ doCompare(tc.path(), m_basePath + expectedFileName);
++ }
++
++ void tst_ltext2id::test2wayTransform_data()
++ {
++ QTest::addColumn<QString>("inFileName");
++ QTest::newRow("cpp file two way transform") << "2way.cpp";
++ QTest::newRow("js file two way transform") << "2way.js";
++ QTest::newRow("qml file two way transform") << "2way.qml";
++ QTest::newRow("ui file two way transform") << "project.ui";
++ }
++
++ void tst_ltext2id::test2wayTransform()
++ {
++ QFETCH(QString, inFileName);
++ QString inFilePath = m_basePath + inFileName;
++
++ QString translationFromOriginal = m_basePath + "original.ts";
++ QString translationFromTransformed = m_basePath + "transformed.ts";
++ FileGuard f1(translationFromOriginal);
++ FileGuard f2(translationFromTransformed);
++
++ TempCopy transformedSourceFile(inFilePath);
++
++ // first route to id based translation file: lupdate to get TS, transform TS
++
++ QProcess lupdate1;
++ lupdate1.start(m_lupdate,
++ QStringList{ transformedSourceFile.path(), "-ts", translationFromOriginal,
++ "-locations", "none" });
++ verify(lupdate1, "lupdate route 1");
++
++ QProcess ltext2id1;
++ ltext2id1.start(m_ltext2id, QStringList{ "--sort-messages", translationFromOriginal },
++ QIODeviceBase::ReadOnly | QIODevice::Text);
++ verify(ltext2id1, "ltext2id route 1");
++
++ // second route to id based translation file: transform source, lupdate on transformed source
++
++ QProcess ltext2id2;
++ ltext2id2.start(m_ltext2id, QStringList{ transformedSourceFile.path() },
++ QIODeviceBase::ReadOnly | QIODevice::Text);
++ verify(ltext2id2, "ltext2id route 2");
++
++ QProcess lupdate2;
++ lupdate2.start(m_lupdate,
++ QStringList{ transformedSourceFile.path(), "-ts", translationFromTransformed,
++ "-sort-messages", "-locations", "none" });
++ verify(lupdate2, "lupdate route 2");
++
++ doCompare(translationFromOriginal, translationFromTransformed);
++ }
++
++ void tst_ltext2id::testTransformationError_data()
++ {
++ QTest::addColumn<QString>("inFileName");
++ QTest::addColumn<QString>("expectedFileName");
++ QTest::addColumn<QString>("expectedOutput");
++ QTest::newRow("cpp transform error")
++ << "fails.cpp" << "fails.cpp.out" << "cpp_expectedoutput.txt";
++ }
++
++ void tst_ltext2id::testTransformationError()
++ {
++ QFETCH(QString, inFileName);
++ QFETCH(QString, expectedFileName);
++ QFETCH(QString, expectedOutput);
++
++ TempCopy tc(m_basePath + inFileName);
++
++ QProcess ltext2id;
++ ltext2id.setProcessChannelMode(QProcess::MergedChannels);
++ ltext2id.start(m_ltext2id, QStringList{ tc.path() }, QIODeviceBase::ReadOnly | QIODevice::Text);
++ QVERIFY2(ltext2id.waitForStarted(), qPrintable(ltext2id.errorString()));
++ QVERIFY2(ltext2id.waitForFinished(10000), qPrintable("ltext2id hung"_L1));
++ QVERIFY2(ltext2id.exitStatus() == QProcess::NormalExit, qPrintable("ltext2id crashed"));
++ QVERIFY(ltext2id.exitCode() == 1);
++
++ doCompare(tc.path(), m_basePath + expectedFileName);
++
++ QFile file(m_basePath + expectedOutput);
++ QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text));
++ QByteArrayList expectedOut = file.readAll().split('\n');
++ QByteArrayList actualOut = ltext2id.readAll().split('\n');
++ auto actualLineItr = actualOut.cbegin();
++ for (const QByteArray &line : std::as_const(expectedOut)) {
++ if (actualLineItr == actualOut.cend())
++ QFAIL("The actual output error does not match with the expected output error. Missing "
++ "errors.");
++ const QByteArray &actualLine = *actualLineItr;
++ QRegularExpression lineRegex(line);
++ auto match = lineRegex.globalMatch(actualLine);
++ if (!match.hasNext() || match.next().captured(0) != actualLine)
++ QFAIL(qPrintable(
++ "The actual output error does not match with the expected output error. "
++ "expected \n'%1'\n got\n'%2'\n"_L1.arg(line)
++ .arg(actualLine)));
++ actualLineItr++;
++ }
++ if (actualLineItr != actualOut.cend())
++ QFAIL("The actual output error produced more errors than expected.");
++ }
++
++ void tst_ltext2id::testProject_data()
++ {
++ QTest::addColumn<QString>("projectName");
++ QTest::addColumn<QString>("expectedProjectName");
++ QTest::newRow("cpp project") << "project" << "project.out";
++ }
++
++ void tst_ltext2id::testProject()
++ {
++ QFETCH(QString, projectName);
++ QFETCH(QString, expectedProjectName);
++ TempCopy tc(m_basePath + projectName);
++ QProcess ltext2id;
++ ltext2id.start(m_ltext2id, QStringList{ tc.path() }, QIODeviceBase::ReadOnly | QIODevice::Text);
++ QVERIFY2(ltext2id.waitForStarted(), qPrintable(ltext2id.errorString()));
++ QVERIFY2(ltext2id.waitForFinished(10000), qPrintable("ltext2id hung"_L1));
++ QVERIFY2(ltext2id.exitStatus() == QProcess::NormalExit, qPrintable("ltext2id crashed"));
++ QVERIFY(ltext2id.exitCode() == 0);
++ QDir expDir(m_basePath + expectedProjectName);
++ QDir actualDir(tc.path());
++ const QStringList files{ "main.cpp", "project.ts" };
++ for (const QString &f : files)
++ doCompare(actualDir.filePath(f), expDir.filePath(f));
++ }
++
++ QTEST_APPLESS_MAIN(tst_ltext2id)
++
++ #include "tst_ltext2id.moc"