Add per-module unit-test dirs for the Release 2 scope

One unit-test/ directory per module/target, mirroring the existing
TetraIF/unit-test/ layout (one QtTest executable per module, guarded by
BUILD_TESTING and the module target, each registered with ctest):

  Lts/Common/CodeGen      codegen_unit_test
  Lts/Common/LtsCoordinate ltscoordinate_unit_test
  Lts/Common/LtsSession   ltssession_unit_test
  Lts/DDS/Core            ddscore_unit_test
  Lts/DDS/QosProfile      ddsqos_unit_test
  Lts/Tools/LogViewer     logviewer_unit_test
  Lts/Tools/SqlBrowser    sqlbrowser_unit_test
  Z/src/NGLLogger         ngllogger_unit_test
  Z/src/WMS_Interface     wms_json_ds_unit_test
  Z/src/nglapplication    nglcoreapplication_unit_test
  Z/src/nglcompression    nglcompression_unit_test
  Z/src/nglsettings       nglsettings_unit_test
  Z/src/ngltranslator     ngltranslator_unit_test

Where a module is built with hidden visibility and has no export macro
(LogViewer, SqlBrowser, ngltranslator, NGLLogger, nglcompression,
nglsettings), its sources are compiled directly into the test
executable, the same pattern as prtcnv_unit_test.

ctest baseline in the container (QT_QPA_PLATFORM=offscreen): 20/23
passed (13 new + 7 pre-existing in-scope tests), 3 Not Run as before
(nglcomp_test, GrantleeParserSmoke, utils_test).

README: ctest baseline table extended to the 23 registered tests and
section 2.4 now documents the per-module test layout. ddsview has no
unit test (GUI tool, XmlHelper needs the DDS dynamic-xtypes runtime).
This commit is contained in:
berrux-dev committed 2026-11-15 01:08:17 +01:00
1 parent a62b729577
commit 09b6df8bc3
40 files changed
+4966 -17

No files matched your search

@@ -64,3 +64,8 @@ if(QT6_GUI_AVAILABLE)
endif()
NGL_INSTALL_TARGET(LogViewer)
#
# Unit tests
#
ADD_SUBDIRECTORY(unit-test)
@@ -0,0 +1,24 @@
#
# Unit tests for the LogViewer module.
# One test executable per module, each registered with ctest.
#
if (NOT BUILD_TESTING)
return()
endif()
# --- LogViewer: log table model + sort/filter proxy.
# --- The module is an executable, so the model sources are compiled directly
# --- into the test (same pattern as the LogView test in TetraIF/unit-test).
if (TARGET LogViewer)
ADD_EXECUTABLE(logviewer_unit_test
logviewer_unit_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../logviewtablemodel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../sortfiltermodel.cpp)
TARGET_LINK_LIBRARIES(logviewer_unit_test
NGLLogger
Qt6::Core
Qt6::Gui
Qt6::Test
)
ADD_TEST(NAME logviewer_unit_test COMMAND $<TARGET_FILE:logviewer_unit_test>)
endif()
@@ -0,0 +1,553 @@
//
// Unit tests for the LogViewer module: LogViewTableModel (log line storage,
// column/role data access, standard level colors) and SortFilterModel
// (string/date filtering and date-aware sorting).
//
// Both classes are the module's non-GUI logic (QAbstractTableModel /
// QSortFilterProxyModel, no widgets needed); they are compiled from the
// LogViewer executable sources. The other LogViewer classes are filter
// widgets and the main window.
//
#include "../logviewtablemodel.h"
#include "../sortfiltermodel.h"
#include <NGLLogger/NGLLoggerBootstrap.h>
#include <QtCore/QDateTime>
#include <QtTest/QTest>
using LogInformation = LogViewTableModel::logInformation;
namespace
{
LogInformation makeLine(const QDateTime &date, const QString &level, const QString &logger,
const QString &data, const QString &location)
{
LogInformation line;
line.date = date;
line.level = level;
line.logger = logger;
line.data = data;
line.location = location;
return line;
}
QDateTime d(const QString &iso)
{
return QDateTime::fromString(iso, Qt::ISODate);
}
} // namespace
class logviewer_unit_test : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
// LogViewTableModel
void tableModelEmptyDimensions();
void tableModelSetDataAndReadRoles();
void tableModelInvalidIndex();
void tableModelHeaderData();
void tableModelStandardLevelColors();
void tableModelAdjustColorsAndReset();
void tableModelAddDataAndLength();
void tableModelSetDataByIndex();
void tableModelStartEndDateTimeSignals();
// SortFilterModel
void proxyStringFilter();
void proxyDateStringFilterEdgeCases();
void proxyDateFilter();
void proxyResetAllFilter();
void proxySortByDate();
void proxySortByLevel();
void proxyFigureFilterOutSignal();
};
void logviewer_unit_test::initTestCase()
{
// LogViewTableModel's constructor resolves its logger from the NGL root
// logger, so it must exist before the model is instantiated.
NGL::NGLLoggerBootstrap::createNGLRootLogger("log4cxx.props", false);
}
// ---------------------------------------------------------------------------
// LogViewTableModel
// ---------------------------------------------------------------------------
void logviewer_unit_test::tableModelEmptyDimensions()
{
LogViewTableModel model;
// note: the overrides in LogViewTableModel redeclare the parameter without
// the base class' default value
QCOMPARE(model.rowCount(QModelIndex()), 0);
QCOMPARE(model.columnCount(QModelIndex()), LogViewTableModel::colLast + 1);
QCOMPARE(model.length(), 0);
}
void logviewer_unit_test::tableModelSetDataAndReadRoles()
{
LogViewTableModel model;
const QDateTime date = d(QStringLiteral("2024-05-06T12:34:56"));
QVector<LogInformation> lines;
lines << makeLine(date, QStringLiteral("INFO"), QStringLiteral("main"), QStringLiteral("hello"),
QStringLiteral("a.cpp:42"))
<< makeLine(d(QStringLiteral("2024-05-07T08:00:00")), QStringLiteral("WARN"),
QStringLiteral("net"), QStringLiteral("world"), QStringLiteral("b.cpp:7"));
model.setData(lines);
QCOMPARE(model.rowCount(QModelIndex()), 2);
QCOMPARE(model.length(), 2);
// DisplayRole per column
QCOMPARE(model.data(model.index(0, LogViewTableModel::colDate), Qt::DisplayRole).toString().
startsWith(QStringLiteral("2024-05-06 12:34:56,")), true);
QCOMPARE(model.data(model.index(0, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("INFO"));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colLogger), Qt::DisplayRole).toString(),
QStringLiteral("main"));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colData), Qt::DisplayRole).toString(),
QStringLiteral("hello"));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colLocation), Qt::DisplayRole).toString(),
QStringLiteral("a.cpp:42"));
// UserRole carries the raw QDateTime of the date column
QCOMPARE(model.data(model.index(0, LogViewTableModel::colDate), Qt::UserRole).toDateTime(), date);
// itemAt: note the date format uses dashes between the time parts
QCOMPARE(model.itemAt(1, LogViewTableModel::colDate).startsWith(QStringLiteral("2024-05-07 08-00-00,")),
true);
QCOMPARE(model.itemAt(0, LogViewTableModel::colLevel), QStringLiteral("INFO"));
QCOMPARE(model.itemAt(0, LogViewTableModel::colData), QStringLiteral("hello"));
// unknown column index -> empty string
QCOMPARE(model.itemAt(0, 99), QString());
// replacing all data
model.setData(QVector<LogInformation>());
QCOMPARE(model.rowCount(QModelIndex()), 0);
}
void logviewer_unit_test::tableModelInvalidIndex()
{
LogViewTableModel model;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("x.cpp:1"));
model.setData(lines);
// invalid index -> invalid QVariant for any role
const QModelIndex invalid = QModelIndex();
QVERIFY(!model.data(invalid, Qt::DisplayRole).isValid());
QVERIFY(!model.data(invalid, Qt::UserRole).isValid());
QVERIFY(!model.data(invalid, Qt::BackgroundRole).isValid());
}
void logviewer_unit_test::tableModelHeaderData()
{
LogViewTableModel model;
QCOMPARE(model.headerData(LogViewTableModel::colDate, Qt::Horizontal, Qt::DisplayRole).toString(),
QStringLiteral("Date"));
QCOMPARE(model.headerData(LogViewTableModel::colLevel, Qt::Horizontal, Qt::DisplayRole).toString(),
QStringLiteral("Level"));
QCOMPARE(model.headerData(LogViewTableModel::colLogger, Qt::Horizontal, Qt::DisplayRole).toString(),
QStringLiteral("Logger"));
QCOMPARE(model.headerData(LogViewTableModel::colData, Qt::Horizontal, Qt::DisplayRole).toString(),
QStringLiteral("Data"));
QCOMPARE(model.headerData(LogViewTableModel::colLocation, Qt::Horizontal, Qt::DisplayRole).toString(),
QStringLiteral("Location"));
// vertical orientation and out-of-range section fall through to
// QAbstractTableModel::headerData (1-based: section+1 horizontal, 1 vertical)
QCOMPARE(model.headerData(0, Qt::Vertical, Qt::DisplayRole).toString(), QStringLiteral("1"));
QCOMPARE(model.headerData(99, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("100"));
}
void logviewer_unit_test::tableModelStandardLevelColors()
{
LogViewTableModel model;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("WARN"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("ERROR"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("FATAL"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("DEBUG"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("TRACE"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("CUSTOM"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
model.setData(lines);
const int col = LogViewTableModel::colLevel;
QCOMPARE(model.data(model.index(0, col), Qt::BackgroundRole).value<QColor>(), Qt::blue);
QCOMPARE(model.data(model.index(0, col), Qt::ForegroundRole).value<QColor>(), Qt::white);
QCOMPARE(model.data(model.index(1, col), Qt::BackgroundRole).value<QColor>(), Qt::yellow);
QCOMPARE(model.data(model.index(1, col), Qt::ForegroundRole).value<QColor>(), Qt::black);
QCOMPARE(model.data(model.index(2, col), Qt::BackgroundRole).value<QColor>(), Qt::darkRed);
QCOMPARE(model.data(model.index(2, col), Qt::ForegroundRole).value<QColor>(), Qt::white);
QCOMPARE(model.data(model.index(3, col), Qt::BackgroundRole).value<QColor>(), Qt::red);
QCOMPARE(model.data(model.index(3, col), Qt::ForegroundRole).value<QColor>(), Qt::black);
QCOMPARE(model.data(model.index(4, col), Qt::BackgroundRole).value<QColor>(), Qt::green);
QCOMPARE(model.data(model.index(4, col), Qt::ForegroundRole).value<QColor>(), Qt::black);
QCOMPARE(model.data(model.index(5, col), Qt::BackgroundRole).value<QColor>(), Qt::lightGray);
QCOMPARE(model.data(model.index(5, col), Qt::ForegroundRole).value<QColor>(), Qt::black);
// unknown level: no color registered -> invalid variants
QVERIFY(!model.data(model.index(6, col), Qt::BackgroundRole).isValid());
QVERIFY(!model.data(model.index(6, col), Qt::ForegroundRole).isValid());
// other columns have no background/foreground color
QVERIFY(!model.data(model.index(0, LogViewTableModel::colData), Qt::BackgroundRole).isValid());
}
void logviewer_unit_test::tableModelAdjustColorsAndReset()
{
LogViewTableModel model;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("WARN"), QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
model.setData(lines);
const int col = LogViewTableModel::colLevel;
// adjustCellColor only affects levels that are already registered
model.adjustCellColor(Qt::red, QStringLiteral("INFO"));
QCOMPARE(model.data(model.index(0, col), Qt::BackgroundRole).value<QColor>(), Qt::red);
// unknown level: no effect
model.adjustCellColor(Qt::green, QStringLiteral("NOPE"));
QCOMPARE(model.data(model.index(1, col), Qt::BackgroundRole).value<QColor>(), Qt::yellow);
// adjustFontColor
model.adjustFontColor(Qt::black, QStringLiteral("INFO"));
QCOMPARE(model.data(model.index(0, col), Qt::ForegroundRole).value<QColor>(), Qt::black);
// reset() clears the data and restores the standard colors
QVERIFY(model.reset());
QCOMPARE(model.length(), 0);
QCOMPARE(model.rowCount(QModelIndex()), 0);
}
void logviewer_unit_test::tableModelAddDataAndLength()
{
LogViewTableModel model;
QCOMPARE(model.length(), 0);
model.addData(makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("l.cpp:1")));
QCOMPARE(model.length(), 1);
model.addData(makeLine(d(QStringLiteral("2024-01-02T00:00:00")), QStringLiteral("WARN"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("l.cpp:1")));
QCOMPARE(model.length(), 2);
QCOMPARE(model.data(model.index(1, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("WARN"));
}
void logviewer_unit_test::tableModelSetDataByIndex()
{
LogViewTableModel model;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("l.cpp:1"));
model.setData(lines);
// only Qt::EditRole is accepted
QVERIFY(!model.setData(model.index(0, LogViewTableModel::colLevel), QStringLiteral("X"),
Qt::DisplayRole));
QVERIFY(model.setData(model.index(0, LogViewTableModel::colLevel), QStringLiteral("ERROR"),
Qt::EditRole));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("ERROR"));
QVERIFY(model.setData(model.index(0, LogViewTableModel::colLogger), QStringLiteral("other"),
Qt::EditRole));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colLogger), Qt::DisplayRole).toString(),
QStringLiteral("other"));
QVERIFY(model.setData(model.index(0, LogViewTableModel::colData), QStringLiteral("new data"),
Qt::EditRole));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colData), Qt::DisplayRole).toString(),
QStringLiteral("new data"));
QVERIFY(model.setData(model.index(0, LogViewTableModel::colLocation), QStringLiteral("m.cpp:9"),
Qt::EditRole));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colLocation), Qt::DisplayRole).toString(),
QStringLiteral("m.cpp:9"));
const QDateTime newDate = d(QStringLiteral("2024-02-02T02:02:02"));
QVERIFY(model.setData(model.index(0, LogViewTableModel::colDate), newDate, Qt::EditRole));
QCOMPARE(model.data(model.index(0, LogViewTableModel::colDate), Qt::UserRole).toDateTime(), newDate);
}
void logviewer_unit_test::tableModelStartEndDateTimeSignals()
{
// empty model: no signals
LogViewTableModel empty;
bool started = false;
bool ended = false;
QObject::connect(&empty, &LogViewTableModel::startTimeSetted, [&started](const QDateTime &) { started = true; });
QObject::connect(&empty, &LogViewTableModel::endTimeSetted, [&ended](const QDateTime &) { ended = true; });
empty.startDateTime();
empty.endDateTime();
QVERIFY(!started);
QVERIFY(!ended);
// populated model: first/last line date
LogViewTableModel model;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-03-01T00:00:00")), QStringLiteral("INFO"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-03-02T00:00:00")), QStringLiteral("WARN"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-03-03T00:00:00")), QStringLiteral("ERROR"), QStringLiteral("l"),
QStringLiteral("d"), QStringLiteral("l.cpp:1"));
model.setData(lines);
QDateTime start;
QDateTime end;
QObject::connect(&model, &LogViewTableModel::startTimeSetted, [&start](const QDateTime &t) { start = t; });
QObject::connect(&model, &LogViewTableModel::endTimeSetted, [&end](const QDateTime &t) { end = t; });
model.startDateTime();
model.endDateTime();
QCOMPARE(start, d(QStringLiteral("2024-03-01T00:00:00")));
QCOMPARE(end, d(QStringLiteral("2024-03-03T00:00:00")));
}
// ---------------------------------------------------------------------------
// SortFilterModel
// ---------------------------------------------------------------------------
void logviewer_unit_test::proxyStringFilter()
{
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"),
QStringLiteral("main"), QStringLiteral("alpha"), QStringLiteral("a.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-02T00:00:00")), QStringLiteral("WARN"),
QStringLiteral("net"), QStringLiteral("beta"), QStringLiteral("b.cpp:2"))
<< makeLine(d(QStringLiteral("2024-01-03T00:00:00")), QStringLiteral("INFO"),
QStringLiteral("net"), QStringLiteral("gamma"), QStringLiteral("c.cpp:3"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
QCOMPARE(proxy.rowCount(), 3);
QVERIFY(proxy.stringFilterIsEmpty());
// setStringFilter itself does not re-run the proxy filter (only the date
// setters and resetAllFilter do); mainwindow.cpp calls
// m_filterProxyModel->invalidate() after every setStringFilter, so the
// tests do the same.
// filter on the level column (col 1)
proxy.setStringFilter(LogViewTableModel::colLevel, QStringLiteral("WARN"));
proxy.invalidate();
QCOMPARE(proxy.rowCount(), 1);
QCOMPARE(proxy.data(proxy.index(0, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("WARN"));
// combined with the logger column (col 2)
proxy.setStringFilter(LogViewTableModel::colLogger, QStringLiteral("net"));
proxy.invalidate();
QCOMPARE(proxy.rowCount(), 1);
QCOMPARE(proxy.data(proxy.index(0, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("WARN"));
// replacing an existing filter: "net" does not match the new text "main"
proxy.setStringFilter(LogViewTableModel::colLogger, QStringLiteral("main"));
proxy.invalidate();
// WARN rows have logger "net" -> nothing left
QCOMPARE(proxy.rowCount(), 0);
// case sensitivity: a fresh column filter is stored as-is and the row data
// "beta" does not match "BETA" (contains() is case sensitive)
proxy.resetAllFilter();
QCOMPARE(proxy.rowCount(), 3);
proxy.setStringFilter(LogViewTableModel::colData, QStringLiteral("BETA"));
proxy.invalidate();
QCOMPARE(proxy.rowCount(), 0);
// setStringFilter keeps the old filter when the new text matches the old
// one case-insensitively, so the stored filter is still "BETA"
proxy.setStringFilter(LogViewTableModel::colData, QStringLiteral("beta"));
proxy.invalidate();
QCOMPARE(proxy.rowCount(), 0);
}
void logviewer_unit_test::proxyDateStringFilterEdgeCases()
{
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T00:00:00")), QStringLiteral("INFO"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
// column range is validated: out-of-range columns are ignored
proxy.setStringFilter(-1, QStringLiteral("x"));
proxy.setStringFilter(5, QStringLiteral("x"));
proxy.setStringFilter(99, QStringLiteral("x"));
QVERIFY(proxy.stringFilterIsEmpty());
QCOMPARE(proxy.rowCount(), 1);
// an empty filter string still counts as "contained" for the activation
// report (columns 3 and 4 only check presence, columns 1 and 2 check for
// non-emptiness)
proxy.setStringFilter(LogViewTableModel::colData, QString());
QVERIFY(!proxy.stringFilterIsEmpty());
QVector<bool> activated = proxy.activatedStringFilter();
QCOMPARE(activated.size(), 5);
QVERIFY(!activated.at(0));
QVERIFY(!activated.at(1));
QVERIFY(!activated.at(2));
QVERIFY(activated.at(3));
QVERIFY(!activated.at(4));
// empty filter does not filter any row (contains("") is always true)
QCOMPARE(proxy.rowCount(), 1);
}
void logviewer_unit_test::proxyDateFilter()
{
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T10:00:00")), QStringLiteral("INFO"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-02T10:00:00")), QStringLiteral("WARN"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-03T10:00:00")), QStringLiteral("ERROR"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
// only start date: filter not active (both ends required)
proxy.setStartDateFilter(d(QStringLiteral("2024-01-01T10:00:00")));
QCOMPARE(proxy.rowCount(), 3);
QVector<bool> activated = proxy.activatedStringFilter();
QVERIFY(!activated.at(0));
// start and end: window is exclusive on both ends
proxy.setEndDateFilter(d(QStringLiteral("2024-01-03T10:00:00")));
QCOMPARE(proxy.rowCount(), 1);
QCOMPARE(proxy.data(proxy.index(0, LogViewTableModel::colDate), Qt::UserRole).toDateTime(),
d(QStringLiteral("2024-01-02T10:00:00")));
activated = proxy.activatedStringFilter();
QVERIFY(activated.at(0));
// setting the same start date again is a no-op (and must not break the filter)
proxy.setStartDateFilter(d(QStringLiteral("2024-01-01T10:00:00")));
QCOMPARE(proxy.rowCount(), 1);
}
void logviewer_unit_test::proxyResetAllFilter()
{
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T10:00:00")), QStringLiteral("INFO"),
QStringLiteral("main"), QStringLiteral("alpha"), QStringLiteral("a.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-02T10:00:00")), QStringLiteral("WARN"),
QStringLiteral("net"), QStringLiteral("beta"), QStringLiteral("b.cpp:2"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
proxy.setStringFilter(LogViewTableModel::colLevel, QStringLiteral("WARN"));
proxy.setStringFilter(LogViewTableModel::colLogger, QStringLiteral("net"));
proxy.setStartDateFilter(d(QStringLiteral("2024-01-01T10:00:00")));
proxy.setEndDateFilter(d(QStringLiteral("2024-01-02T10:00:00")));
QCOMPARE(proxy.rowCount(), 0);
proxy.resetAllFilter();
QVERIFY(proxy.stringFilterIsEmpty());
QCOMPARE(proxy.rowCount(), 2);
QVector<bool> activated = proxy.activatedStringFilter();
for (const bool active : activated)
QVERIFY(!active);
}
void logviewer_unit_test::proxySortByDate()
{
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-03T10:00:00")), QStringLiteral("INFO"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-01T10:00:00")), QStringLiteral("WARN"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-02T10:00:00")), QStringLiteral("ERROR"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
proxy.sort(LogViewTableModel::colDate, Qt::AscendingOrder);
QCOMPARE(proxy.rowCount(), 3);
QCOMPARE(proxy.data(proxy.index(0, LogViewTableModel::colDate), Qt::UserRole).toDateTime(),
d(QStringLiteral("2024-01-01T10:00:00")));
QCOMPARE(proxy.data(proxy.index(1, LogViewTableModel::colDate), Qt::UserRole).toDateTime(),
d(QStringLiteral("2024-01-02T10:00:00")));
QCOMPARE(proxy.data(proxy.index(2, LogViewTableModel::colDate), Qt::UserRole).toDateTime(),
d(QStringLiteral("2024-01-03T10:00:00")));
}
void logviewer_unit_test::proxySortByLevel()
{
// non-date columns fall back to the default QVariant comparison
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T10:00:00")), QStringLiteral("WARN"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-02T10:00:00")), QStringLiteral("INFO"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"))
<< makeLine(d(QStringLiteral("2024-01-03T10:00:00")), QStringLiteral("DEBUG"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
proxy.sort(LogViewTableModel::colLevel, Qt::AscendingOrder);
QCOMPARE(proxy.data(proxy.index(0, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("DEBUG"));
QCOMPARE(proxy.data(proxy.index(1, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("INFO"));
QCOMPARE(proxy.data(proxy.index(2, LogViewTableModel::colLevel), Qt::DisplayRole).toString(),
QStringLiteral("WARN"));
}
void logviewer_unit_test::proxyFigureFilterOutSignal()
{
LogViewTableModel source;
QVector<LogInformation> lines;
lines << makeLine(d(QStringLiteral("2024-01-01T10:00:00")), QStringLiteral("INFO"),
QStringLiteral("l"), QStringLiteral("d"), QStringLiteral("l.cpp:1"));
source.setData(lines);
SortFilterModel proxy;
proxy.setSourceModel(&source);
QVector<bool> lastFilter;
int calls = 0;
QObject::connect(&proxy, &SortFilterModel::theseFilterAreActivated,
[&lastFilter, &calls](const QVector<bool> &filter) {
lastFilter = filter;
++calls;
});
proxy.figureFilterOut();
QCOMPARE(calls, 1);
QCOMPARE(lastFilter, QVector<bool>({false, false, false, false, false}));
proxy.setStringFilter(LogViewTableModel::colLevel, QStringLiteral("INFO"));
proxy.figureFilterOut();
QCOMPARE(calls, 2);
QCOMPARE(lastFilter, QVector<bool>({false, true, false, false, false}));
}
QTEST_MAIN(logviewer_unit_test)
#include "logviewer_unit_test.moc"
@@ -61,3 +61,8 @@ if(QT6_GUI_AVAILABLE)
)
endif()
NGL_INSTALL_TARGET(sqlbrowser)
#
# Unit tests
#
ADD_SUBDIRECTORY(unit-test)
@@ -0,0 +1,23 @@
#
# Unit tests for the SqlBrowser module.
# One test executable per module, each registered with ctest.
#
if (NOT BUILD_TESTING)
return()
endif()
# --- SqlBrowser: data model + add-table model.
# --- The module is an executable, so the model sources are compiled directly
# --- into the test (same pattern as the LogView test in TetraIF/unit-test).
if (TARGET sqlbrowser)
ADD_EXECUTABLE(sqlbrowser_unit_test
sqlbrowser_unit_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../DataModel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../AddModel.cpp)
TARGET_LINK_LIBRARIES(sqlbrowser_unit_test
Qt6::Core
Qt6::Gui
Qt6::Test
)
ADD_TEST(NAME sqlbrowser_unit_test COMMAND $<TARGET_FILE:sqlbrowser_unit_test>)
endif()
@@ -0,0 +1,410 @@
//
// Unit tests for the SqlBrowser module: DataModel (double-buffered table
// model with role handling) and AddModel (add-row table model).
//
// These are the module's pure non-GUI logic classes. No database access is
// involved: the DB work lives in QueryWorker/QueryThread (QSqlDatabase on a
// worker thread). The remaining SqlBrowser classes are either widgets
// (Query, BrowserWnd, LoginWnd, WaitWnd, AddLines) or DB/thread driven.
// PresetManager is skipped as well: its constructor calls
// QApplication::arguments() and its save path uses QInputDialog, so it
// requires a running GUI application.
//
#include "../DataModel.h"
#include "../AddModel.h"
#include <QtCore/QMetaType>
#include <QtCore/QPair>
#include <QtCore/QRegularExpression>
#include <QtGui/QColor>
#include <QtTest/QTest>
class sqlbrowser_unit_test : public QObject
{
Q_OBJECT
private slots:
// DataModel
void dataModelEmptyState();
void dataModelSwapLists();
void dataModelDataRoleHandling();
void dataModelToolTipTimestamp();
void dataModelHeaderData();
void dataModelTableEditability();
void dataModelSlotSetData();
void dataModelSetDataAsyncSemantics();
void dataModelDeleteLines();
// AddModel
void addModelBasics();
void addModelSetDataCreatesRow();
void addModelStringifiesValues();
void addModelRemoveRow();
void addModelClear();
void addModelTableName();
};
// --- DataModel ----------------------------------------------------------------
void sqlbrowser_unit_test::dataModelEmptyState()
{
DataModel model;
QCOMPARE(model.rowCount(), 0);
QCOMPARE(model.columnCount(), 0);
QVERIFY(model.getTableName().isEmpty());
Qt::ItemFlags flags = model.flags(QModelIndex());
QVERIFY(flags & Qt::ItemIsSelectable);
QVERIFY(flags & Qt::ItemIsEnabled);
QVERIFY(!(flags & Qt::ItemIsEditable));
}
void sqlbrowser_unit_test::dataModelSwapLists()
{
DataModel model;
// columns/rows are staged in the write buffer first
model.addColumn(QStringLiteral("id"), QMetaType::Int);
model.addColumn(QStringLiteral("name"), QMetaType::QString);
model.addColumn(QStringLiteral("ts"), QMetaType::LongLong);
Data row0;
row0 << 1 << QStringLiteral("alpha") << static_cast<qint64>(1700000000000LL);
model.addLine(row0);
// nothing is visible in the live model before the swap
QCOMPARE(model.rowCount(), 0);
QCOMPARE(model.columnCount(), 0);
model.slotSwapLists();
QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.columnCount(), 3);
const HeaderList &live = model.getHeaderList();
QCOMPARE(live.size(), 3);
QCOMPARE(live.at(0).first, QStringLiteral("id"));
QVERIFY(live.at(0).second == QMetaType::Int);
QCOMPARE(live.at(1).first, QStringLiteral("name"));
QVERIFY(live.at(1).second == QMetaType::QString);
QCOMPARE(live.at(2).first, QStringLiteral("ts"));
QVERIFY(live.at(2).second == QMetaType::LongLong);
// the write buffer is cleared by the swap: new staging does not leak
// into the live model
Data row1;
row1 << 999 << QStringLiteral("leak");
model.addLine(row1);
QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toInt(), 1);
}
void sqlbrowser_unit_test::dataModelDataRoleHandling()
{
DataModel model;
model.addColumn(QStringLiteral("id"), QMetaType::Int);
model.addColumn(QStringLiteral("name"), QMetaType::QString);
model.addColumn(QStringLiteral("ts"), QMetaType::LongLong);
Data row0;
row0 << 1 << QStringLiteral("alpha") << static_cast<qint64>(1700000000000LL);
Data row1;
row1 << 2 << QVariant() << static_cast<qint64>(5);
Data row2;
row2 << 3 << QStringLiteral("gamma") << QVariant();
model.addLine(row0);
model.addLine(row1);
model.addLine(row2);
model.slotSwapLists();
QCOMPARE(model.rowCount(), 3);
QCOMPARE(model.columnCount(), 3);
// DisplayRole and EditRole both return the raw value
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toInt(), 1);
QCOMPARE(model.data(model.index(0, 1), Qt::DisplayRole).toString(), QStringLiteral("alpha"));
QCOMPARE(model.data(model.index(0, 2), Qt::EditRole).toLongLong(), qint64(1700000000000));
// null cells are highlighted via BackgroundRole; filled cells are not
QCOMPARE(model.data(model.index(1, 1), Qt::BackgroundRole).value<QColor>(), QColor(255, 255, 215));
QVERIFY(!model.data(model.index(0, 0), Qt::BackgroundRole).isValid());
// other roles return nothing
QVERIFY(!model.data(model.index(0, 1), Qt::DecorationRole).isValid());
// a valid parent index means "no children"
QCOMPARE(model.rowCount(model.index(0, 0)), 0);
QCOMPARE(model.columnCount(model.index(0, 0)), 0);
}
void sqlbrowser_unit_test::dataModelToolTipTimestamp()
{
DataModel model;
model.addColumn(QStringLiteral("id"), QMetaType::Int);
model.addColumn(QStringLiteral("name"), QMetaType::QString);
model.addColumn(QStringLiteral("ts"), QMetaType::LongLong);
Data row0;
row0 << 1 << QStringLiteral("alpha") << static_cast<qint64>(1700000000000);
Data row1;
row1 << 2 << QStringLiteral("beta") << static_cast<qint64>(5);
model.addLine(row0);
model.addLine(row1);
model.slotSwapLists();
// 64 bit integer values above 1000000000 are rendered as timestamps
const QVariant tooltip = model.data(model.index(0, 2), Qt::ToolTipRole);
QVERIFY(tooltip.isValid());
const QRegularExpression pattern(QStringLiteral("^\\d{2}\\.\\d{2}\\.\\d{4} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}$"));
QVERIFY(pattern.match(tooltip.toString()).hasMatch());
// below the threshold -> no tooltip
QVERIFY(!model.data(model.index(1, 2), Qt::ToolTipRole).isValid());
// non-integer types -> no tooltip
QVERIFY(!model.data(model.index(0, 1), Qt::ToolTipRole).isValid());
QVERIFY(!model.data(model.index(0, 0), Qt::ToolTipRole).isValid());
}
void sqlbrowser_unit_test::dataModelHeaderData()
{
DataModel model;
model.addColumn(QStringLiteral("id"), QMetaType::Int);
model.addColumn(QStringLiteral("name"), QMetaType::QString);
model.addColumn(QStringLiteral("ts"), QMetaType::LongLong);
model.slotSwapLists();
QCOMPARE(model.headerData(0, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("id"));
QCOMPARE(model.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("name"));
QCOMPARE(model.headerData(2, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("ts"));
// ToolTipRole carries the QMetaType name of the column
QCOMPARE(model.headerData(0, Qt::Horizontal, Qt::ToolTipRole).toString(), QStringLiteral("int"));
QCOMPARE(model.headerData(1, Qt::Horizontal, Qt::ToolTipRole).toString(), QStringLiteral("QString"));
QCOMPARE(model.headerData(2, Qt::Horizontal, Qt::ToolTipRole).toString(), QStringLiteral("qlonglong"));
// out-of-range section and vertical orientation fall through to
// QAbstractTableModel::headerData (1-based: section+1 horizontal, 1 vertical)
QCOMPARE(model.headerData(9, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("10"));
QCOMPARE(model.headerData(0, Qt::Vertical, Qt::DisplayRole).toString(), QStringLiteral("1"));
}
void sqlbrowser_unit_test::dataModelTableEditability()
{
DataModel model;
// custom queries (empty table name) are read-only
QVERIFY(!(model.flags(QModelIndex()) & Qt::ItemIsEditable));
// table queries are editable
model.setTableName(QStringLiteral("users"));
QCOMPARE(model.getTableName(), QStringLiteral("users"));
QVERIFY(model.flags(QModelIndex()) & Qt::ItemIsEditable);
model.setTableName(QString());
QVERIFY(!(model.flags(QModelIndex()) & Qt::ItemIsEditable));
}
void sqlbrowser_unit_test::dataModelSlotSetData()
{
DataModel model;
model.addColumn(QStringLiteral("id"), QMetaType::Int);
model.addColumn(QStringLiteral("name"), QMetaType::QString);
Data row0;
row0 << 1 << QStringLiteral("alpha");
Data row1;
row1 << 2 << QStringLiteral("beta");
model.addLine(row0);
model.addLine(row1);
model.slotSwapLists();
// slotSetData applies the write to the live list (the DB answered)
model.slotSetData(model.index(1, 1), QStringLiteral("delta"));
QCOMPARE(model.data(model.index(1, 1), Qt::DisplayRole).toString(), QStringLiteral("delta"));
QCOMPARE(model.data(model.index(0, 1), Qt::DisplayRole).toString(), QStringLiteral("alpha"));
QCOMPARE(model.rowCount(), 2);
}
void sqlbrowser_unit_test::dataModelSetDataAsyncSemantics()
{
DataModel model;
model.addColumn(QStringLiteral("id"), QMetaType::Int);
Data row0;
row0 << 1;
model.addLine(row0);
model.slotSwapLists();
bool captured = false;
QVariant capturedValue;
DataModel *capturedModel = nullptr;
QObject::connect(&model, &DataModel::signalSetData,
[&captured, &capturedValue, &capturedModel](DataModel *m, const QModelIndex &,
const QVariant &v) {
captured = true;
capturedModel = m;
capturedValue = v;
});
// EditRole: value is NOT applied locally; it is forwarded to the worker
// and the call reports "not done yet"
QVERIFY(!model.setData(model.index(0, 0), QStringLiteral("changed"), Qt::EditRole));
QVERIFY(captured);
QCOMPARE(capturedModel, static_cast<DataModel *>(&model));
QCOMPARE(capturedValue.toString(), QStringLiteral("changed"));
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toInt(), 1); // untouched
// non-EditRole values are accepted (true) and ignored
QVERIFY(model.setData(model.index(0, 0), QStringLiteral("ignored"), Qt::DisplayRole));
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toInt(), 1);
}
void sqlbrowser_unit_test::dataModelDeleteLines()
{
DataModel model;
model.addColumn(QStringLiteral("id"), QMetaType::Int);
Data row0;
row0 << 1;
Data row1;
row1 << 2;
Data row2;
row2 << 3;
model.addLine(row0);
model.addLine(row1);
model.addLine(row2);
model.slotSwapLists();
QCOMPARE(model.rowCount(), 3);
// delete in descending row order so the indices stay valid
QList<QModelIndex> toDelete;
toDelete << model.index(2, 0) << model.index(0, 0);
model.deleteLines(toDelete);
QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toInt(), 2);
// an empty index list is a no-op
model.deleteLines(QList<QModelIndex>());
QCOMPARE(model.rowCount(), 1);
}
// --- AddModel --------------------------------------------------------------------
void sqlbrowser_unit_test::addModelBasics()
{
AddModel model;
// rowCount is always dataList + 1 (the trailing empty row for new data)
QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.columnCount(), 0);
HeaderList headers;
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("id"), QMetaType::Int));
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("name"), QMetaType::QString));
model.setHeaderList(headers);
QCOMPARE(model.columnCount(), 2);
QCOMPARE(model.rowCount(), 1);
QVERIFY(model.getColumnType(0) == QMetaType::Int);
QVERIFY(model.getColumnType(1) == QMetaType::QString);
QVERIFY(model.getColumnType(-1) == QMetaType::UnknownType);
QVERIFY(model.getColumnType(2) == QMetaType::UnknownType);
QCOMPARE(model.headerData(0, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("id"));
QCOMPARE(model.headerData(1, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("name"));
// only horizontal + displayRole
QVERIFY(!model.headerData(0, Qt::Vertical, Qt::DisplayRole).isValid());
QVERIFY(!model.headerData(0, Qt::Horizontal, Qt::EditRole).isValid());
const Qt::ItemFlags flags = model.flags(QModelIndex());
QVERIFY(flags & Qt::ItemIsSelectable);
QVERIFY(flags & Qt::ItemIsEnabled);
QVERIFY(flags & Qt::ItemIsEditable);
}
void sqlbrowser_unit_test::addModelSetDataCreatesRow()
{
AddModel model;
HeaderList headers;
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("id"), QMetaType::Int));
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("name"), QMetaType::QString));
model.setHeaderList(headers);
QCOMPARE(model.rowCount(), 1);
// writing into the empty row materializes it
QVERIFY(model.setData(model.index(0, 0), QStringLiteral("42"), Qt::EditRole));
QCOMPARE(model.rowCount(), 2);
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("42"));
// other columns of the new row are pre-filled with empty strings
QCOMPARE(model.data(model.index(0, 1), Qt::DisplayRole).toString(), QString());
// the trailing empty row still has no data
QVERIFY(!model.data(model.index(1, 0), Qt::DisplayRole).isValid());
// a second write materializes row 1
QVERIFY(model.setData(model.index(1, 1), QStringLiteral("bob"), Qt::EditRole));
QCOMPARE(model.rowCount(), 3);
QCOMPARE(model.data(model.index(1, 1), Qt::DisplayRole).toString(), QStringLiteral("bob"));
QCOMPARE(model.data(model.index(1, 0), Qt::DisplayRole).toString(), QString());
// non-EditRole values are rejected
QVERIFY(!model.setData(model.index(0, 0), QStringLiteral("x"), Qt::DisplayRole));
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("42"));
}
void sqlbrowser_unit_test::addModelStringifiesValues()
{
AddModel model;
HeaderList headers;
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("id"), QMetaType::Int));
model.setHeaderList(headers);
// stored values are stringified on write
QVERIFY(model.setData(model.index(0, 0), 42, Qt::EditRole));
const QVariant value = model.data(model.index(0, 0), Qt::DisplayRole);
QCOMPARE(value.toString(), QStringLiteral("42"));
QVERIFY(value.typeId() == QMetaType::QString);
}
void sqlbrowser_unit_test::addModelRemoveRow()
{
AddModel model;
HeaderList headers;
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("id"), QMetaType::Int));
model.setHeaderList(headers);
QVERIFY(model.setData(model.index(0, 0), QStringLiteral("1"), Qt::EditRole));
QVERIFY(model.setData(model.index(1, 0), QStringLiteral("2"), Qt::EditRole));
QCOMPARE(model.rowCount(), 3);
// out-of-range row
QVERIFY(!model.removeRow(10));
QCOMPARE(model.rowCount(), 3);
QVERIFY(model.removeRow(0));
QCOMPARE(model.rowCount(), 2);
QCOMPARE(model.data(model.index(0, 0), Qt::DisplayRole).toString(), QStringLiteral("2"));
}
void sqlbrowser_unit_test::addModelClear()
{
AddModel model;
HeaderList headers;
headers.append(QPair<QString, QMetaType::Type>(QStringLiteral("id"), QMetaType::Int));
model.setHeaderList(headers);
QVERIFY(model.setData(model.index(0, 0), QStringLiteral("1"), Qt::EditRole));
QCOMPARE(model.rowCount(), 2);
model.clear();
QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.columnCount(), 1); // header list survives clear()
}
void sqlbrowser_unit_test::addModelTableName()
{
AddModel model;
QCOMPARE(model.getTableName(), QString());
model.setTablename(QStringLiteral("t_users"));
QCOMPARE(model.getTableName(), QStringLiteral("t_users"));
}
QTEST_MAIN(sqlbrowser_unit_test)
#include "sqlbrowser_unit_test.moc"