diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 02d39c1d3..7c296d6b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1069,7 +1069,8 @@ jobs: --filter 'src/' \ --exclude 'src/OgreXML/' \ --exclude 'src/dependencies/' \ - --exclude '.*/(LLMManager|LLMWorker|ModelDownloader|SDManager)\.cpp' \ + --exclude '.*/(LLMManager|LLMWorker|ModelDownloader|SDManager|AIChatManager)\.cpp' \ + --exclude '.*/(AIChatManager|SDManager|SDWorker)\.h' \ --exclude '.*_test\.cpp' \ --exclude '.*TestHelpers\.h' \ --exclude '.*QtAppEnvironment_test\.cpp' \ diff --git a/sonar-project.properties b/sonar-project.properties index 92227c361..09155d7a2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -24,7 +24,8 @@ sonar.exclusions=**/OgreXML/**,**/dependencies/**,**/*_autogen/**,**/CMakeFiles/ # Coverage exclusions - exclude test infrastructure only sonar.coverage.exclusions=**/*_test.cpp,**/test_*.cpp,tests/**/*.cpp,tests/**/*.qml,\ **/*_autogen/**,**/TestHelpers.h,**/test_main.cpp,\ - **/LLMManager.cpp,**/LLMWorker.cpp,**/ModelDownloader.cpp,**/SDManager.cpp + **/LLMManager.cpp,**/LLMWorker.cpp,**/ModelDownloader.cpp,**/SDManager.cpp,\ + **/AIChatManager.cpp,**/AIChatManager.h,**/SDManager.h,**/SDWorker.h # Coverage settings for C++ projects # Generic coverage report (SonarQube XML format from gcovr) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 4e5f68aef..f83e32d38 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -875,6 +875,21 @@ TEST(CLIPipelineCmdFixError, NonexistentFileWithFlagsAndLongOutputFlag) EXPECT_EQ(CLIPipeline::cmdFix(args.argc(), args.argv()), 1); } +TEST(CLIPipelineCmdFixError, ExistingInvalidFileWithAllFlagReturnsError) +{ + const QString file = QDir::tempPath() + "/cli_test_fix_existing_invalid_input.fbx"; + QFile invalid(file); + ASSERT_TRUE(invalid.open(QIODevice::WriteOnly | QIODevice::Text)); + invalid.write("invalid fbx payload"); + invalid.close(); + + QByteArray fileBa = file.toUtf8(); + TestArgv args({"qtmesh", "fix", fileBa.constData(), "--all"}); + EXPECT_EQ(CLIPipeline::cmdFix(args.argc(), args.argv()), 1); + + QFile::remove(file); +} + // -- cmdFix success paths -- TEST_F(CLIPipelineCmdTest, CmdFix_Basic) @@ -1016,6 +1031,21 @@ TEST(CLIPipelineCmdAnimError, MergeModeWithMissingBaseFile) EXPECT_EQ(CLIPipeline::cmdAnim(args.argc(), args.argv()), 1); } +TEST(CLIPipelineCmdAnimError, RenameModeWithoutOutputUsesDefaultOutputPath) +{ + TestArgv args({"qtmesh", "anim", "/tmp/nonexistent_cli_test_rename_default_33333.fbx", + "--rename", "OldAnimName", "NewAnimName"}); + EXPECT_EQ(CLIPipeline::cmdAnim(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdAnimError, MergeModeWithoutOutputUsesDefaultOutputPath) +{ + TestArgv args({"qtmesh", "anim", "/tmp/nonexistent_cli_test_merge_default_44444.fbx", + "--merge", "/tmp/nonexistent_anim_source_44444_a.fbx", + "/tmp/nonexistent_anim_source_44444_b.fbx"}); + EXPECT_EQ(CLIPipeline::cmdAnim(args.argc(), args.argv()), 1); +} + // -- cmdAnim list -- TEST_F(CLIPipelineCmdTest, CmdAnimList_Text) @@ -1621,6 +1651,18 @@ TEST(CLIPipelineCmdLodError, NonexistentFileWithRemoveMode) EXPECT_EQ(CLIPipeline::cmdLod(args.argc(), args.argv()), 1); } +TEST(CLIPipelineCmdLodError, NonexistentFileWithInfoAndJsonMode) +{ + TestArgv args({"qtmesh", "lod", "/tmp/nonexistent_cli_lod_info_67890.fbx", "--info", "--json"}); + EXPECT_EQ(CLIPipeline::cmdLod(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdLodError, InvalidCountValueReportsModeError) +{ + TestArgv args({"qtmesh", "lod", "/tmp/nonexistent_cli_lod_count_abc.fbx", "--count", "abc"}); + EXPECT_EQ(CLIPipeline::cmdLod(args.argc(), args.argv()), 2); +} + class CLIPipelineCmdLodTest : public ::testing::Test { protected: void SetUp() override { diff --git a/src/MaterialEditorQML_test.cpp b/src/MaterialEditorQML_test.cpp index 0d2b1c083..0dbe987a4 100644 --- a/src/MaterialEditorQML_test.cpp +++ b/src/MaterialEditorQML_test.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include "MaterialEditorQML.h" #include "Manager.h" #include @@ -138,6 +140,66 @@ TEST_F(MaterialEditorQMLTest, FileSystem_ListDirectory) { EXPECT_EQ(empty.size(), 0); } +TEST_F(MaterialEditorQMLTest, FileSystem_ListDirectoryFiltersOnlyImagesAndDirectories) { + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + + const QString subdirPath = tempDir.filePath("textures"); + ASSERT_TRUE(QDir().mkpath(subdirPath)); + + QFile imageFile(tempDir.filePath("albedo.png")); + ASSERT_TRUE(imageFile.open(QIODevice::WriteOnly)); + imageFile.write("png"); + imageFile.close(); + + QFile textFile(tempDir.filePath("notes.txt")); + ASSERT_TRUE(textFile.open(QIODevice::WriteOnly)); + textFile.write("txt"); + textFile.close(); + + QVariantList entries = editor->listDirectory(tempDir.path()); + ASSERT_GE(entries.size(), 2); + + bool foundDir = false; + bool foundImage = false; + bool foundText = false; + for (const QVariant& v : entries) { + const QVariantMap item = v.toMap(); + const QString name = item.value("name").toString(); + const QString type = item.value("type").toString(); + if (name == "textures" && type == "dir") foundDir = true; + if (name == "albedo.png" && type == "file") foundImage = true; + if (name == "notes.txt") foundText = true; + } + + EXPECT_TRUE(foundDir); + EXPECT_TRUE(foundImage); + EXPECT_FALSE(foundText); +} + +TEST_F(MaterialEditorQMLTest, FileSystem_GetFileSizeStringCoversKbAndMbBranches) { + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + + const QString kbPath = tempDir.filePath("kb.bin"); + QFile kbFile(kbPath); + ASSERT_TRUE(kbFile.open(QIODevice::WriteOnly)); + kbFile.write(QByteArray(2048, 'k')); + kbFile.close(); + + const QString mbPath = tempDir.filePath("mb.bin"); + QFile mbFile(mbPath); + ASSERT_TRUE(mbFile.open(QIODevice::WriteOnly)); + mbFile.write(QByteArray(2 * 1024 * 1024, 'm')); + mbFile.close(); + + const QString kbSize = editor->getFileSizeString(kbPath); + const QString mbSize = editor->getFileSizeString(mbPath); + + EXPECT_TRUE(kbSize.contains("KB")); + EXPECT_TRUE(mbSize.contains("MB")); +} + // =========================================================================== // Basic fixture tests -- enum name getters // =========================================================================== @@ -1877,6 +1939,65 @@ TEST_F(MaterialEditorQMLTest, LLMProperties_InitialState) { Q_UNUSED(model); } +TEST_F(MaterialEditorQMLTest, GenerateMaterialFromPrompt_EmptyPromptEmitsError) { + QSignalSpy errorSpy(editor.get(), &MaterialEditorQML::aiGenerationError); + + editor->generateMaterialFromPrompt(""); + + ASSERT_EQ(errorSpy.count(), 1); + const QList args = errorSpy.takeFirst(); + ASSERT_EQ(args.size(), 1); + EXPECT_EQ(args.at(0).toString(), "Please enter a prompt"); +} + +TEST_F(MaterialEditorQMLTest, GenerateMaterialFromPrompt_NoModelLoadedEmitsError) { + if (editor->llmModelLoaded()) { + GTEST_SKIP() << "LLM model is already loaded in this environment"; + } + + QSignalSpy errorSpy(editor.get(), &MaterialEditorQML::aiGenerationError); + + editor->generateMaterialFromPrompt("polished metal with scratches"); + + ASSERT_GE(errorSpy.count(), 1); + const QList args = errorSpy.takeFirst(); + ASSERT_EQ(args.size(), 1); + EXPECT_TRUE(args.at(0).toString().contains("No AI model loaded")); +} + +TEST_F(MaterialEditorQMLTest, GenerateTextureFromPrompt_EmptyPromptEmitsError) { + QSignalSpy errorSpy(editor.get(), &MaterialEditorQML::sdGenerationError); + + editor->generateTextureFromPrompt("", 512, 512); + + ASSERT_EQ(errorSpy.count(), 1); + const QList args = errorSpy.takeFirst(); + ASSERT_EQ(args.size(), 1); + EXPECT_EQ(args.at(0).toString(), "Please enter a texture prompt"); +} + +TEST_F(MaterialEditorQMLTest, GenerateTextureFromPrompt_ReportsUnavailableBackendOrModel) { + QSignalSpy errorSpy(editor.get(), &MaterialEditorQML::sdGenerationError); + + editor->generateTextureFromPrompt("brushed steel", 512, 512); + + ASSERT_GE(errorSpy.count(), 1); + const QList args = errorSpy.takeFirst(); + ASSERT_EQ(args.size(), 1); + const QString message = args.at(0).toString(); + + if (editor->stableDiffusionEnabled()) { + EXPECT_TRUE(message.contains("No SD model loaded") || message.contains("AI Settings")); + } else { + EXPECT_TRUE(message.contains("Stable Diffusion support is not enabled")); + } +} + +TEST_F(MaterialEditorQMLTest, StopGenerationMethodsWithoutActiveJobsDoNotCrash) { + EXPECT_NO_THROW(editor->stopAIGeneration()); + EXPECT_NO_THROW(editor->stopTextureGeneration()); +} + // =========================================================================== // Additional coverage tests (MaterialEditorQMLTest fixture) // =========================================================================== diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 146b89eff..26ce3eadc 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -7,10 +7,12 @@ #include #include #include +#include #include #include #include #include +#include // NOTE: These access-specifier redefinitions are a pragmatic test-only workaround // to cover MainWindow internals. Prefer dedicated test APIs or friend tests when feasible. @@ -143,6 +145,11 @@ TEST_F(MainWindowTest, KeyFFrameSelectionEmptyDoesNotCrash) { EXPECT_NO_THROW(window->keyPressEvent(&event)); } +TEST_F(MainWindowTest, KeyReleaseEventDoesNotCrash) { + QKeyEvent event(QEvent::KeyRelease, Qt::Key_W, Qt::NoModifier); + EXPECT_NO_THROW(window->keyReleaseEvent(&event)); +} + // keyReleaseEvent is protected — tested implicitly via keyPressEvent // ---- setPlaying ---- @@ -318,6 +325,29 @@ TEST_F(MainWindowTest, OpenRecentFileWithoutActionSenderDoesNothing) { EXPECT_TRUE(window->mUriList.isEmpty()); } +TEST_F(MainWindowTest, OpenRecentFileRemovesMissingPathFromSettings) { + const QString missingPath = tempDir.filePath("missing.mesh"); + window->addToRecentFiles(missingPath); + QAction* action = recentFileAction(0); + ASSERT_NE(action, nullptr); + ASSERT_EQ(action->data().toString(), missingPath); + + // Auto-close the warning QMessageBox shown by openRecentFile(). + QTimer::singleShot(0, []() { + for (QWidget* w : QApplication::topLevelWidgets()) { + if (auto* box = qobject_cast(w)) { + box->accept(); + } + } + }); + + action->trigger(); + + const QStringList files = QSettings().value("RecentFiles/files").toStringList(); + EXPECT_FALSE(files.contains(missingPath)); + EXPECT_FALSE(window->mUriList.contains(missingPath)); +} + TEST_F(MainWindowTest, ToolbarTogglesUpdateWidgetVisibility) { window->on_actionObjects_Toolbar_toggled(false); EXPECT_TRUE(window->ui->objectsToolbar->isHidden());