Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/CLIPipeline_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <QJsonObject>
#include <QJsonArray>
#include <QSettings>
#include <QTemporaryDir>
#include <vector>
#include <OgreMeshManager.h>
#include <OgreHardwareBufferManager.h>
Expand Down Expand Up @@ -598,6 +599,24 @@ class TestArgv {
int m_argc = 0;
};

/// RAII helper to temporarily switch current working directory.
class ScopedCurrentDir {
public:
explicit ScopedCurrentDir(const QString& path)
: m_old(QDir::currentPath())
{
QDir::setCurrent(path);
}

~ScopedCurrentDir()
{
QDir::setCurrent(m_old);
}

private:
QString m_old;
};

} // anonymous namespace

// --- initOgreHeadless tests ---
Expand Down Expand Up @@ -1749,3 +1768,150 @@ TEST_F(CLIPipelineCmdLodTest, CmdLod_InfoAndRemoveFromGeneratedMesh)
QFile::remove(removedOut);
QFile::remove(QDir::tempPath() + "/cli_lod_removed.material");
}

// ==========================================================================
// cmdScan tests
// ==========================================================================

TEST(CLIPipelineCmdScanError, MissingConfigFileReturns2)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());

const QString missingConfig = tmpDir.filePath("qtmesh_scan_missing_config.yml");
QFile::remove(missingConfig); // Ensure this path does not exist.
ASSERT_FALSE(QFileInfo::exists(missingConfig));

QByteArray configBa = missingConfig.toUtf8();
TestArgv args({"qtmesh", "scan", "--config", configBa.constData()});
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 2);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

TEST(CLIPipelineCmdScanError, InvalidFailOnReturns2)
{
TestArgv args({"qtmesh", "scan", "--fail-on", "fatal"});
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 2);
}

TEST(CLIPipelineCmdScanError, NonDirectoryScanRootReturns2)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
const QString filePath = tmpDir.filePath("not_a_directory.txt");
QFile f(filePath);
ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write("x");
f.close();

QByteArray fileBa = filePath.toUtf8();
TestArgv args({"qtmesh", "scan", fileBa.constData()});
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 2);
}

TEST(CLIPipelineCmdScan, ReportAndSarifAreWrittenWithFailOnNever)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());

// Invalid FBX triggers a deterministic load_error finding without Ogre.
const QString scanFile = tmpDir.filePath("bad.fbx");
QFile invalid(scanFile);
ASSERT_TRUE(invalid.open(QIODevice::WriteOnly | QIODevice::Text));
invalid.write("not a real fbx");
invalid.close();

const QString reportPath = tmpDir.filePath("out/report.json");
const QString sarifPath = tmpDir.filePath("out/report.sarif");
QFile::remove(reportPath);
QFile::remove(sarifPath);

QByteArray rootBa = tmpDir.path().toUtf8();
QByteArray reportBa = reportPath.toUtf8();
QByteArray sarifBa = sarifPath.toUtf8();
TestArgv args({"qtmesh", "scan", rootBa.constData(),
"--json",
"--report", reportBa.constData(),
"--sarif", sarifBa.constData(),
"--fail-on", "never"});

EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 0);
EXPECT_TRUE(QFile::exists(reportPath));
EXPECT_TRUE(QFile::exists(sarifPath));

QFile reportFile(reportPath);
ASSERT_TRUE(reportFile.open(QIODevice::ReadOnly | QIODevice::Text));
const QString report = QString::fromUtf8(reportFile.readAll());
EXPECT_TRUE(report.contains("\"summary\""));
EXPECT_TRUE(report.contains("\"load_error\""));

QFile sarifFile(sarifPath);
ASSERT_TRUE(sarifFile.open(QIODevice::ReadOnly | QIODevice::Text));
const QString sarif = QString::fromUtf8(sarifFile.readAll());
EXPECT_TRUE(sarif.contains("\"version\": \"2.1.0\""));
EXPECT_TRUE(sarif.contains("\"tool\""));
}

TEST(CLIPipelineCmdScan, IncludePatternNormalizesBareExtension)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());

QDir root(tmpDir.path());
ASSERT_TRUE(root.mkpath("nested/deeper"));

// If "*.fbx" is normalized to "**/*.fbx", this nested file is scanned,
// causing load_error and non-zero exit with default fail_on=error.
const QString nestedFbx = tmpDir.filePath("nested/deeper/model.fbx");
QFile invalid(nestedFbx);
ASSERT_TRUE(invalid.open(QIODevice::WriteOnly | QIODevice::Text));
invalid.write("invalid fbx payload");
invalid.close();

QByteArray rootBa = tmpDir.path().toUtf8();
TestArgv args({"qtmesh", "scan", rootBa.constData(), "--include", "*.fbx"});
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 1);
}

TEST(CLIPipelineCmdScan, AutoDetectConfigWritesConfiguredReports)
{
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
ScopedCurrentDir cwd(tmpDir.path());

QDir root(tmpDir.path());
ASSERT_TRUE(root.mkpath("assets"));

const QString scanFile = tmpDir.filePath("assets/auto_bad.fbx");
QFile invalid(scanFile);
ASSERT_TRUE(invalid.open(QIODevice::WriteOnly | QIODevice::Text));
invalid.write("invalid fbx");
invalid.close();

const QString configPath = tmpDir.filePath("qtmesh.yml");
QFile cfg(configPath);
ASSERT_TRUE(cfg.open(QIODevice::WriteOnly | QIODevice::Text));
cfg.write(
"report:\n"
" format: json\n"
" output: auto/report.json\n"
" sarif_output: auto/report.sarif\n"
" fail_on: never\n");
cfg.close();

QFile::remove(tmpDir.filePath("auto/report.json"));
QFile::remove(tmpDir.filePath("auto/report.sarif"));

QByteArray rootBa = tmpDir.filePath("assets").toUtf8();
TestArgv args({"qtmesh", "scan", rootBa.constData()});
EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 0);

const QString autoReport = tmpDir.filePath("auto/report.json");
const QString autoSarif = tmpDir.filePath("auto/report.sarif");
EXPECT_TRUE(QFile::exists(autoReport));
EXPECT_TRUE(QFile::exists(autoSarif));

QFile reportFile(autoReport);
ASSERT_TRUE(reportFile.open(QIODevice::ReadOnly | QIODevice::Text));
const QString report = QString::fromUtf8(reportFile.readAll());
EXPECT_TRUE(report.contains("\"summary\""));
}
86 changes: 86 additions & 0 deletions src/mainwindow_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,89 @@ TEST_F(MainWindowTest, UpdateMergeAnimationsButtonResolvesEntitiesFromSelectedNo

EXPECT_TRUE(window->ui->actionMerge_Animations->isEnabled());
}

TEST_F(MainWindowTest, KeyPCyclesPivotMode) {
auto* transformOperator = TransformOperator::getSingleton();
ASSERT_NE(transformOperator, nullptr);
const auto before = transformOperator->pivotMode();

QKeyEvent event(QEvent::KeyPress, Qt::Key_P, Qt::NoModifier);
window->keyPressEvent(&event);

EXPECT_NE(transformOperator->pivotMode(), before);
}

TEST_F(MainWindowTest, GroupActionsEnablementFollowsSelectionState) {
auto* manager = Manager::getSingleton();
ASSERT_NE(manager, nullptr);

Ogre::SceneNode* nodeA = manager->addSceneNode("GroupEnableNodeA");
Ogre::SceneNode* nodeB = manager->addSceneNode("GroupEnableNodeB");
ASSERT_NE(nodeA, nullptr);
ASSERT_NE(nodeB, nullptr);

SelectionSet::getSingleton()->clear();
app->processEvents();
EXPECT_FALSE(window->ui->actionGroup->isEnabled());
EXPECT_FALSE(window->ui->actionUngroup->isEnabled());

SelectionSet::getSingleton()->append(nodeA);
app->processEvents();
EXPECT_FALSE(window->ui->actionGroup->isEnabled());
EXPECT_FALSE(window->ui->actionUngroup->isEnabled());

SelectionSet::getSingleton()->append(nodeB);
app->processEvents();
EXPECT_TRUE(window->ui->actionGroup->isEnabled());
EXPECT_FALSE(window->ui->actionUngroup->isEnabled());
}

TEST_F(MainWindowTest, GroupThenUngroupSelectedNodesUpdatesSelectionAndActions) {
auto* manager = Manager::getSingleton();
ASSERT_NE(manager, nullptr);

Ogre::SceneNode* nodeA = manager->addSceneNode("GroupFlowNodeA");
Ogre::SceneNode* nodeB = manager->addSceneNode("GroupFlowNodeB");
ASSERT_NE(nodeA, nullptr);
ASSERT_NE(nodeB, nullptr);

SelectionSet::getSingleton()->clear();
SelectionSet::getSingleton()->append(nodeA);
SelectionSet::getSingleton()->append(nodeB);
app->processEvents();
ASSERT_EQ(SelectionSet::getSingleton()->getNodesCount(), 2);

window->groupSelected();
app->processEvents();

ASSERT_EQ(SelectionSet::getSingleton()->getNodesCount(), 1);
Ogre::SceneNode* groupNode = SelectionSet::getSingleton()->getSceneNode(0);
ASSERT_NE(groupNode, nullptr);
EXPECT_TRUE(manager->isGroupNode(groupNode));
EXPECT_TRUE(window->ui->actionUngroup->isEnabled());

window->ungroupSelected();
app->processEvents();

EXPECT_EQ(SelectionSet::getSingleton()->getNodesCount(), 2);
EXPECT_TRUE(window->ui->actionGroup->isEnabled());
EXPECT_FALSE(window->ui->actionUngroup->isEnabled());
}

TEST_F(MainWindowTest, UngroupSelectedIgnoresNonGroupNode) {
auto* manager = Manager::getSingleton();
ASSERT_NE(manager, nullptr);

Ogre::SceneNode* node = manager->addSceneNode("UngroupNoopNode");
ASSERT_NE(node, nullptr);
ASSERT_FALSE(manager->isGroupNode(node));

SelectionSet::getSingleton()->selectOne(node);
app->processEvents();
EXPECT_FALSE(window->ui->actionUngroup->isEnabled());

window->ungroupSelected();

EXPECT_TRUE(manager->hasSceneNode("UngroupNoopNode"));
EXPECT_EQ(SelectionSet::getSingleton()->getNodesCount(), 1);
}
37 changes: 32 additions & 5 deletions website/src/DocsApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const NAV = [
{ section: 'Integration', items: [
{ id: 'docker', label: 'Docker' },
{ id: 'github-actions', label: 'GitHub Actions' },
{ id: 'gitlab-ci', label: 'GitLab CI' },
{ id: 'ci-cd', label: 'CI/CD Patterns' },
]},
];
Expand Down Expand Up @@ -682,12 +683,12 @@ docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh \\

<section className={s.section} id="github-actions">
<h2 className={s.sectionTitle}>GitHub Actions</h2>
<h3 className={s.subsection}>Reusable Action</h3>
<CodeBlock lang="yaml">{`- uses: fernandotonon/QtMeshEditor/.github/actions/qtmesh@master
<h3 className={s.subsection}>Reusable Action (scan example)</h3>
<CodeBlock lang="yaml">{`- uses: fernandotonon/QtMeshEditor/.github/actions/qtmesh@9cfc829e8b255994ef92ba228c687e3dd2254119
with:
command: info
input-file: assets/character.fbx
options: --json`}</CodeBlock>
command: scan
input-file: assets
options: --config qtmesh.yml --sarif scan-report.sarif --report scan-report.json --fail-on error`}</CodeBlock>

<h3 className={s.subsection}>Direct Docker Usage</h3>
<CodeBlock lang="yaml">{`- name: Scan assets
Expand All @@ -707,6 +708,32 @@ docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh \\
sarif_file: scan-report.sarif`}</CodeBlock>
</section>

<section className={s.section} id="gitlab-ci">
<h2 className={s.sectionTitle}>GitLab CI</h2>
<p className={s.para}>
Use the Docker image directly in <Code>.gitlab-ci.yml</Code> to run scan checks and keep reports as artifacts.
</p>
<CodeBlock lang="yaml">{`stages:
- lint

asset_scan:
stage: lint
image: ghcr.io/fernandotonon/qtmesh:2.23.0
entrypoint: [""]
script:
- qtmesheditor --cli scan \${CI_PROJECT_DIR}/assets \\
--config \${CI_PROJECT_DIR}/qtmesh.yml \\
--sarif \${CI_PROJECT_DIR}/scan-report.sarif \\
--report \${CI_PROJECT_DIR}/scan-report.json \\
--fail-on error
Comment thread
coderabbitai[bot] marked this conversation as resolved.
artifacts:
when: always
paths:
- scan-report.sarif
- scan-report.json
expire_in: 7 days`}</CodeBlock>
</section>

<section className={s.section} id="ci-cd">
<h2 className={s.sectionTitle}>CI/CD Patterns</h2>
<h3 className={s.subsection}>PR Gate: Block Merges on Asset Errors</h3>
Expand Down
Loading