diff --git a/CMakeLists.txt b/CMakeLists.txt
index 750e143441..120b85596c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -66,6 +66,10 @@ endif()
add_subdirectory(CuteLogger)
add_subdirectory(src)
add_subdirectory(translations)
+include(CTest)
+if(BUILD_TESTING)
+ add_subdirectory(tests)
+endif()
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
diff --git a/scripts/build-shotcut-msys2.sh b/scripts/build-shotcut-msys2.sh
index 639cdd722b..0a494db701 100755
--- a/scripts/build-shotcut-msys2.sh
+++ b/scripts/build-shotcut-msys2.sh
@@ -615,7 +615,7 @@ function set_globals {
#####
# shotcut
- CONFIG[4]="cmake -G Ninja -D CMAKE_INSTALL_PREFIX=$FINAL_INSTALL_DIR -DCMAKE_PREFIX_PATH=$QTDIR -D SHOTCUT_VERSION=$SHOTCUT_VERSION $CMAKE_DEBUG_FLAG"
+ CONFIG[4]="cmake -G Ninja -D CMAKE_INSTALL_PREFIX=$FINAL_INSTALL_DIR -DCMAKE_PREFIX_PATH=$QTDIR -D SHOTCUT_VERSION=$SHOTCUT_VERSION -D BUILD_TESTING=OFF $CMAKE_DEBUG_FLAG"
CFLAGS_[4]="$ASAN_CFLAGS $CFLAGS"
LDFLAGS_[4]="$ASAN_LDFLAGS $LDFLAGS"
BUILD[4]="ninja -j $MAKEJ"
diff --git a/scripts/build-shotcut.sh b/scripts/build-shotcut.sh
index c5209f8e72..6c7d4d7c4c 100755
--- a/scripts/build-shotcut.sh
+++ b/scripts/build-shotcut.sh
@@ -918,7 +918,7 @@ function set_globals {
#####
# shotcut
- CONFIG[7]="cmake -G Ninja -D CMAKE_PREFIX_PATH=$QTDIR -D SHOTCUT_VERSION=$SHOTCUT_VERSION $CMAKE_DEBUG_FLAG"
+ CONFIG[7]="cmake -G Ninja -D CMAKE_PREFIX_PATH=$QTDIR -D SHOTCUT_VERSION=$SHOTCUT_VERSION -D BUILD_TESTING=OFF $CMAKE_DEBUG_FLAG"
if test "$TARGET_OS" = "Darwin" ; then
CONFIG[7]="${CONFIG[7]} -D CMAKE_INSTALL_PREFIX=."
CONFIG[7]="${CONFIG[7]} -D CMAKE_OSX_ARCHITECTURES='arm64;x86_64'"
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 1662ae669f..f551c64ab7 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -1,6 +1,6 @@
find_package(Qt6 REQUIRED COMPONENTS Core)
-add_executable(shotcut WIN32 MACOSX_BUNDLE
+add_library(shotcut_objects OBJECT
abstractproducerwidget.cpp abstractproducerwidget.h
actions.cpp actions.h
autosavefile.cpp autosavefile.h
@@ -79,7 +79,6 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE
jobs/screencapturejob.cpp jobs/screencapturejob.h
jobs/videoqualityjob.cpp jobs/videoqualityjob.h
jobs/whisperjob.cpp jobs/whisperjob.h
- main.cpp
mainwindow.cpp mainwindow.h
mainwindow.ui
mltcontroller.cpp mltcontroller.h
@@ -233,6 +232,9 @@ add_executable(shotcut WIN32 MACOSX_BUNDLE
../icons/resources.qrc
)
+add_executable(shotcut WIN32 MACOSX_BUNDLE main.cpp)
+target_link_libraries(shotcut PRIVATE shotcut_objects)
+
add_custom_target(OTHER_FILES
SOURCES
../.github/ISSUE_TEMPLATE.md
@@ -266,8 +268,8 @@ add_custom_target(OTHER_FILES
../scripts/staple.sh
)
-target_link_libraries(shotcut
- PRIVATE
+target_link_libraries(shotcut_objects
+ PUBLIC
CuteLogger
PkgConfig::mlt++
PkgConfig::FFTW
@@ -284,22 +286,22 @@ target_link_libraries(shotcut
Qt6::Xml
)
if(UNIX AND NOT APPLE)
- target_link_libraries(shotcut PRIVATE Qt6::DBus X11::X11)
- target_sources(shotcut PRIVATE linuxtools.cpp linuxtools.h)
+ target_link_libraries(shotcut_objects PUBLIC Qt6::DBus X11::X11)
+ target_sources(shotcut_objects PRIVATE linuxtools.cpp linuxtools.h)
endif()
file(GLOB_RECURSE QML_SRC "qml/*")
-target_sources(shotcut PRIVATE ${QML_SRC})
+target_sources(shotcut_objects PRIVATE ${QML_SRC})
-target_include_directories(shotcut PRIVATE ${CMAKE_SOURCE_DIR}/CuteLogger/include)
-target_compile_definitions(shotcut PRIVATE SHOTCUT_VERSION="${SHOTCUT_VERSION}")
+target_include_directories(shotcut_objects PUBLIC ${CMAKE_SOURCE_DIR}/CuteLogger/include)
+target_compile_definitions(shotcut_objects PUBLIC SHOTCUT_VERSION="${SHOTCUT_VERSION}")
# Add compile definitions when certain custom cache variables are ON
if(EXTERNAL_LAUNCHERS)
- target_compile_definitions(shotcut PRIVATE EXTERNAL_LAUNCHERS)
+ target_compile_definitions(shotcut_objects PUBLIC EXTERNAL_LAUNCHERS)
endif()
if(USE_VULKAN)
- target_compile_definitions(shotcut PRIVATE USE_VULKAN)
+ target_compile_definitions(shotcut_objects PUBLIC USE_VULKAN)
endif()
if(WIN32)
@@ -309,22 +311,22 @@ if(WIN32)
target_sources(shotcut PRIVATE ${CMAKE_SOURCE_DIR}/packaging/windows/shotcut.rc)
# Windows integration features
- target_sources(shotcut PRIVATE windowstools.cpp windowstools.h)
- target_sources(shotcut PRIVATE widgets/d3dvideowidget.h widgets/d3dvideowidget.cpp)
- target_sources(shotcut PRIVATE widgets/openglvideowidget.h widgets/openglvideowidget.cpp)
- target_link_libraries(shotcut PRIVATE d3d11 d3dcompiler ole32)
+ target_sources(shotcut_objects PRIVATE windowstools.cpp windowstools.h)
+ target_sources(shotcut_objects PRIVATE widgets/d3dvideowidget.h widgets/d3dvideowidget.cpp)
+ target_sources(shotcut_objects PRIVATE widgets/openglvideowidget.h widgets/openglvideowidget.cpp)
+ target_link_libraries(shotcut_objects PUBLIC d3d11 d3dcompiler ole32)
# Runtime exception handler for debug only
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "AMD64")
- target_include_directories(shotcut PRIVATE ${CMAKE_SOURCE_DIR}/drmingw/include)
- target_link_directories(shotcut PRIVATE ${CMAKE_SOURCE_DIR}/drmingw/x64/lib)
+ target_include_directories(shotcut_objects PUBLIC ${CMAKE_SOURCE_DIR}/drmingw/include)
+ target_link_directories(shotcut_objects PUBLIC ${CMAKE_SOURCE_DIR}/drmingw/x64/lib)
target_link_libraries(shotcut PRIVATE debug exchndl)
endif()
if(WINDOWS_DEPLOY)
install(TARGETS shotcut RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX})
else()
- target_compile_definitions(shotcut PRIVATE NODEPLOY)
+ target_compile_definitions(shotcut_objects PUBLIC NODEPLOY)
install(TARGETS shotcut RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
endif()
@@ -332,18 +334,18 @@ if(WIN32)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/filter-sets DESTINATION ${CMAKE_INSTALL_PREFIX}/share/shotcut/)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/voices DESTINATION ${CMAKE_INSTALL_PREFIX}/share/shotcut/)
else()
- target_sources(shotcut PRIVATE widgets/openglvideowidget.h widgets/openglvideowidget.cpp)
+ target_sources(shotcut_objects PRIVATE widgets/openglvideowidget.h widgets/openglvideowidget.cpp)
endif()
if(APPLE)
- target_sources(shotcut PRIVATE macos.mm macos.h
+ target_sources(shotcut_objects PRIVATE macos.mm macos.h
widgets/metalvideowidget.h widgets/metalvideowidget.mm)
set_target_properties(shotcut PROPERTIES
OUTPUT_NAME "Shotcut"
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/packaging/macos/Info.plist.in)
find_library(FOUNDATION Foundation)
find_library(COCOA Cocoa)
- target_link_libraries(shotcut PRIVATE ${FOUNDATION} ${COCOA})
+ target_link_libraries(shotcut_objects PUBLIC ${FOUNDATION} ${COCOA})
set(APP_ICON ${CMAKE_SOURCE_DIR}/packaging/macos/shotcut.icns)
target_sources(shotcut PRIVATE ${APP_ICON})
set_source_files_properties(${APP_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
new file mode 100644
index 0000000000..b009d087e4
--- /dev/null
+++ b/tests/CMakeLists.txt
@@ -0,0 +1 @@
+add_subdirectory(mainwindow)
diff --git a/tests/mainwindow/CMakeLists.txt b/tests/mainwindow/CMakeLists.txt
new file mode 100644
index 0000000000..a4f7855fd3
--- /dev/null
+++ b/tests/mainwindow/CMakeLists.txt
@@ -0,0 +1,10 @@
+find_package(Qt6 REQUIRED COMPONENTS Test)
+
+add_executable(test_mainwindow mainwindowtest.cpp)
+target_link_libraries(test_mainwindow PRIVATE shotcut_objects Qt6::Test)
+target_include_directories(test_mainwindow PRIVATE ${CMAKE_SOURCE_DIR}/src)
+
+add_test(NAME MainWindowTest COMMAND test_mainwindow)
+set_tests_properties(MainWindowTest PROPERTIES
+ ENVIRONMENT "QT_QPA_PLATFORM=offscreen"
+)
diff --git a/tests/mainwindow/mainwindowtest.cpp b/tests/mainwindow/mainwindowtest.cpp
new file mode 100644
index 0000000000..35235855aa
--- /dev/null
+++ b/tests/mainwindow/mainwindowtest.cpp
@@ -0,0 +1,377 @@
+/*
+ * Copyright (c) 2026 Meltytech, LLC
+ *
+ * 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 "actions.h"
+#include "mainwindow.h"
+#include "mltcontroller.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+class TestMainWindow : public QObject
+{
+ Q_OBJECT
+private slots:
+ void initTestCase()
+ {
+ QStandardPaths::setTestModeEnabled(true);
+
+ // Mirror the setup done by Application in main.cpp
+ QCoreApplication::setOrganizationName("Meltytech");
+ QCoreApplication::setOrganizationDomain("shotcut.org");
+ QCoreApplication::setApplicationName("Shotcut");
+ QCoreApplication::setApplicationVersion(SHOTCUT_VERSION);
+
+ // Suppress CuteLogger output: register an appender at Fatal level only
+ // so the "no appenders" fallback to stderr is never triggered.
+ auto *appender = new ConsoleAppender;
+ appender->setDetailsLevel(Logger::Fatal);
+ cuteLogger->registerAppender(appender);
+ }
+
+ // ── LayoutMode enum ──────────────────────────────────────────────────────
+ void test_layoutModeEnumValues()
+ {
+ QCOMPARE(static_cast(MainWindow::Custom), 0);
+ QCOMPARE(static_cast(MainWindow::Logging), 1);
+ QCOMPARE(static_cast(MainWindow::Editing), 2);
+ QCOMPARE(static_cast(MainWindow::Effects), 3);
+ QCOMPARE(static_cast(MainWindow::Color), 4);
+ QCOMPARE(static_cast(MainWindow::Audio), 5);
+ QCOMPARE(static_cast(MainWindow::PlayerOnly), 6);
+ }
+
+ // ── Singleton ────────────────────────────────────────────────────────────
+ void test_singletonReturnsSameInstance()
+ {
+ MainWindow &w1 = MAIN;
+ MainWindow &w2 = MAIN;
+ QVERIFY(&w1 == &w2);
+ }
+
+ // ── Initial file / project state ─────────────────────────────────────────
+ void test_initialFileNameIsEmpty()
+ {
+ QVERIFY(MAIN.fileName().isEmpty());
+ }
+
+ void test_initialMultitrackNotValid()
+ {
+ QVERIFY(!MAIN.isMultitrackValid());
+ }
+
+ void test_initialPlaylistNotValid()
+ {
+ QVERIFY(!MAIN.isPlaylistValid());
+ }
+
+ void test_initialClipboardNotNewer()
+ {
+ QVERIFY(!MAIN.isClipboardNewer());
+ }
+
+ // ── Undo stack ───────────────────────────────────────────────────────────
+ void test_undoStackNotNull()
+ {
+ QVERIFY(MAIN.undoStack() != nullptr);
+ }
+
+ void test_undoStackInitiallyClean()
+ {
+ QVERIFY(MAIN.undoStack()->isClean());
+ }
+
+ void test_undoStackCannotUndoInitially()
+ {
+ QVERIFY(!MAIN.undoStack()->canUndo());
+ }
+
+ void test_undoStackCannotRedoInitially()
+ {
+ QVERIFY(!MAIN.undoStack()->canRedo());
+ }
+
+ // ── Dock and controller pointers ─────────────────────────────────────────
+ void test_playlistDockNotNull()
+ {
+ QVERIFY(MAIN.playlistDock() != nullptr);
+ }
+
+ void test_timelineDockNotNull()
+ {
+ QVERIFY(MAIN.timelineDock() != nullptr);
+ }
+
+ void test_filterControllerNotNull()
+ {
+ QVERIFY(MAIN.filterController() != nullptr);
+ }
+
+ void test_customProfileMenuNotNull()
+ {
+ QVERIFY(MAIN.customProfileMenu() != nullptr);
+ }
+
+ void test_actionAddCustomProfileNotNull()
+ {
+ QVERIFY(MAIN.actionAddCustomProfile() != nullptr);
+ }
+
+ void test_actionProfileRemoveNotNull()
+ {
+ QVERIFY(MAIN.actionProfileRemove() != nullptr);
+ }
+
+ void test_profileGroupNotNull()
+ {
+ QVERIFY(MAIN.profileGroup() != nullptr);
+ }
+
+ // ── Window properties ────────────────────────────────────────────────────
+ void test_windowAcceptDrops()
+ {
+ QVERIFY(MAIN.acceptDrops());
+ }
+
+ void test_windowNotModifiedInitially()
+ {
+ QVERIFY(!MAIN.isWindowModified());
+ }
+
+ void test_windowDockNestingEnabled()
+ {
+ QVERIFY(MAIN.isDockNestingEnabled());
+ }
+
+ void test_windowTitleContainsApplicationName()
+ {
+ QVERIFY(MAIN.windowTitle().contains(QCoreApplication::applicationName()));
+ }
+
+ // ── File path helpers ─────────────────────────────────────────────────────
+ void test_untitledFileNameEndsWithUntitledMlt()
+ {
+ QVERIFY(MAIN.untitledFileName().endsWith("__untitled__.mlt"));
+ }
+
+ void test_untitledFileNameIsAbsolutePath()
+ {
+ QVERIFY(QFileInfo(MAIN.untitledFileName()).isAbsolute());
+ }
+
+ // ── Dock visibility ───────────────────────────────────────────────────────
+ void test_keyframesDockNotVisibleInitially()
+ {
+ QVERIFY(!MAIN.keyframesDockIsVisible());
+ }
+
+ // ── Player actions registered ─────────────────────────────────────────────
+ void test_actionPlayerPlayPauseRegistered()
+ {
+ QVERIFY(Actions["playerPlayPauseAction"] != nullptr);
+ }
+
+ void test_actionPlayerLoopRegistered()
+ {
+ QVERIFY(Actions["playerLoopAction"] != nullptr);
+ }
+
+ void test_actionPlayerFastForwardRegistered()
+ {
+ QVERIFY(Actions["playerFastForwardAction"] != nullptr);
+ }
+
+ void test_actionPlayerRewindRegistered()
+ {
+ QVERIFY(Actions["playerRewindAction"] != nullptr);
+ }
+
+ void test_actionPlayerSkipNextRegistered()
+ {
+ QVERIFY(Actions["playerSkipNextAction"] != nullptr);
+ }
+
+ void test_actionPlayerSkipPreviousRegistered()
+ {
+ QVERIFY(Actions["playerSkipPreviousAction"] != nullptr);
+ }
+
+ void test_actionPlayerSeekStartRegistered()
+ {
+ QVERIFY(Actions["playerSeekStartAction"] != nullptr);
+ }
+
+ void test_actionPlayerSeekEndRegistered()
+ {
+ QVERIFY(Actions["playerSeekEndAction"] != nullptr);
+ }
+
+ void test_actionPlayerNextFrameRegistered()
+ {
+ QVERIFY(Actions["playerNextFrameAction"] != nullptr);
+ }
+
+ void test_actionPlayerPreviousFrameRegistered()
+ {
+ QVERIFY(Actions["playerPreviousFrameAction"] != nullptr);
+ }
+
+ // ── MainWindow actions registered ─────────────────────────────────────────
+ void test_actionTimelineReloadRegistered()
+ {
+ QVERIFY(Actions["timelineReload"] != nullptr);
+ }
+
+ void test_actionPropertiesRenameClipRegistered()
+ {
+ QVERIFY(Actions["propertiesRenameClipAction"] != nullptr);
+ }
+
+ void test_actionRecentFindRegistered()
+ {
+ QVERIFY(Actions["recentFindAction"] != nullptr);
+ }
+
+ void test_actionAnalyzeFiltersRegistered()
+ {
+ QVERIFY(Actions["analyzeFilters"] != nullptr);
+ }
+
+ // ── Timeline dock actions registered ─────────────────────────────────────
+ void test_actionTimelineAddAudioTrackRegistered()
+ {
+ QVERIFY(Actions["timelineAddAudioTrackAction"] != nullptr);
+ }
+
+ void test_actionTimelineAddVideoTrackRegistered()
+ {
+ QVERIFY(Actions["timelineAddVideoTrackAction"] != nullptr);
+ }
+
+ void test_actionTimelineInsertTrackRegistered()
+ {
+ QVERIFY(Actions["timelineInsertTrackAction"] != nullptr);
+ }
+
+ void test_actionTimelineSelectAllRegistered()
+ {
+ QVERIFY(Actions["timelineSelectAllAction"] != nullptr);
+ }
+
+ void test_actionTimelineSelectNoneRegistered()
+ {
+ QVERIFY(Actions["timelineSelectNoneAction"] != nullptr);
+ }
+
+ void test_actionTimelineToggleTrackLockedRegistered()
+ {
+ QVERIFY(Actions["timelineToggleTrackLockedAction"] != nullptr);
+ }
+
+ void test_actionTimelineToggleTrackMuteRegistered()
+ {
+ QVERIFY(Actions["timelineToggleTrackMuteAction"] != nullptr);
+ }
+
+ // ── Filters dock actions registered ──────────────────────────────────────
+ void test_actionFiltersAddFilterRegistered()
+ {
+ QVERIFY(Actions["filtersAddFilterAction"] != nullptr);
+ }
+
+ void test_actionFiltersRemoveFilterRegistered()
+ {
+ QVERIFY(Actions["filtersRemoveFilterAction"] != nullptr);
+ }
+
+ void test_actionFiltersCopyFiltersRegistered()
+ {
+ QVERIFY(Actions["filtersCopyFiltersAction"] != nullptr);
+ }
+
+ void test_actionFiltersPasteFiltersRegistered()
+ {
+ QVERIFY(Actions["filtersPasteFiltersAction"] != nullptr);
+ }
+
+ // ── Files and playlist dock actions registered ────────────────────────────
+ void test_actionFilesSearchRegistered()
+ {
+ QVERIFY(Actions["filesSearch"] != nullptr);
+ }
+
+ void test_actionPlaylistSearchRegistered()
+ {
+ QVERIFY(Actions["playlistSearch"] != nullptr);
+ }
+
+ // ── MLT profile ───────────────────────────────────────────────────────────
+ void test_mltProfileWidthPositive()
+ {
+ QVERIFY(MLT.profile().width() > 0);
+ }
+
+ void test_mltProfileHeightPositive()
+ {
+ QVERIFY(MLT.profile().height() > 0);
+ }
+
+ void test_mltProfileFpsPositive()
+ {
+ QVERIFY(MLT.profile().fps() > 0.0);
+ }
+
+ // ── Timeline empty-state helpers ──────────────────────────────────────────
+ void test_bottomVideoTrackIndexNegativeWithEmptyTimeline()
+ {
+ QCOMPARE(MAIN.bottomVideoTrackIndex(), -1);
+ }
+
+ void test_mltIndexForTrackNegativeWithEmptyTimeline()
+ {
+ QCOMPARE(MAIN.mltIndexForTrack(0), -1);
+ }
+
+ // ── isSourceClipMyProject ─────────────────────────────────────────────────
+ void test_isSourceClipMyProjectReturnsFalseForEmptyPath()
+ {
+ QVERIFY(!MAIN.isSourceClipMyProject("", false));
+ }
+
+ void test_isSourceClipMyProjectReturnsFalseForNonMatchingPath()
+ {
+ QVERIFY(!MAIN.isSourceClipMyProject("/nonexistent/file.mp4", false));
+ }
+
+ // ── Signal emission ───────────────────────────────────────────────────────
+ void test_setProfileEmitsProfileChanged()
+ {
+ QSignalSpy spy(&MAIN, &MainWindow::profileChanged);
+ MAIN.setProfile("dv_pal");
+ QCOMPARE(spy.count(), 1);
+ MAIN.setProfile(""); // restore to automatic
+ }
+};
+
+QTEST_MAIN(TestMainWindow)
+#include "mainwindowtest.moc"