Files
ResInsight/ApplicationLibCode/UnitTests/RimWorkflowJobSync-Test.cpp
T
Kristian Bendiksen 12bcd5b857 #15044 Workflows: Add option to collapse tasks to their title
Collapse a task with the chevron in its title, a double-click on the title, or
the context menus. A collapsed task shows its title, state and issue count, and
its connections meet in one dot on each side. Ports and config values are shown
in the tooltip. The collapsed set is stored per workflow.
2026-11-04 15:17:05 +01:00

422 lines
21 KiB
C++

/////////////////////////////////////////////////////////////////////////////////
//
// Copyright (C) 2026- Equinor ASA
//
// ResInsight 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.
//
// ResInsight 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 at <http://www.gnu.org/licenses/gpl.html>
// for more details.
//
/////////////////////////////////////////////////////////////////////////////////
#include "gtest/gtest.h"
#include "RiaTestDataDirectory.h"
#include "RimWorkflow.h"
#include "RimWorkflowDefinitionTools.h"
#include "RimWorkflowHelperProcess.h"
#include "RimWorkflowJob.h"
#include "RimWorkflowSchemaTools.h"
#include "RimWorkflowTaskInput.h"
#include "RimWorkflowValidationTools.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>
namespace
{
RimWorkflowDefinition loadDataDefinition()
{
QFile file( QString( "%1/RimWorkflowDefinition/load_data.json" ).arg( TEST_DATA_DIR ) );
EXPECT_TRUE( file.open( QIODevice::ReadOnly ) );
auto definition = RimWorkflowDefinitionTools::fromJson( QJsonDocument::fromJson( file.readAll() ).object() );
EXPECT_TRUE( definition.has_value() );
return definition.value_or( RimWorkflowDefinition() );
}
QJsonArray graphTasks( const RimWorkflowDefinition& definition )
{
return RimWorkflowDefinitionTools::graphFromDefinition( definition, true ).value( "tasks" ).toArray();
}
QString pathValue( const RimWorkflowJob& job, const QString& taskName )
{
return job.inputValues().value( taskName ).toObject().value( "path" ).toString();
}
} // namespace
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowJobSync, oneTaskInputPerTask )
{
const auto definition = loadDataDefinition();
RimWorkflowJob job;
job.syncTaskInputs( graphTasks( definition ) );
ASSERT_EQ( 4u, job.taskInputs().size() );
EXPECT_EQ( "connect_to_resinsight", job.taskInputs()[0]->taskName() );
EXPECT_EQ( 0u, job.taskInputs()[0]->count() );
EXPECT_EQ( "resinsight.load_model", job.taskInput( "load_model" )->taskType() );
EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_model" ) );
// Tasks without config fields are not written
EXPECT_FALSE( job.inputValues().contains( "connect_to_resinsight" ) );
EXPECT_EQ( QJsonArray( { "/path/to/property.roffasc" } ),
job.literalInputValues().value( "import_grid_property" ).toObject().value( "paths" ).toArray() );
QTemporaryDir directory;
const QString path = job.writeInputYaml( directory.filePath( "input.yaml" ) );
QFile file( path );
ASSERT_TRUE( file.open( QIODevice::ReadOnly ) );
EXPECT_EQ( job.inputValues(), QJsonDocument::fromJson( file.readAll() ).object() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowJobSync, valuesFollowRenamesAndRemovals )
{
auto definition = loadDataDefinition();
RimWorkflowJob job;
job.syncTaskInputs( graphTasks( definition ) );
RimWorkflowTaskInput* loadModel = job.taskInput( "load_model" );
// The binding object and its value are kept when the task is renamed
auto renamed = RimWorkflowDefinitionTools::renameTask( definition, "load_model", "load_grid" );
ASSERT_TRUE( renamed.has_value() );
renamed->inputs["load_grid"] = QJsonObject();
job.syncTaskInputs( graphTasks( renamed.value() ), { { "load_model", "load_grid" } } );
EXPECT_EQ( loadModel, job.taskInput( "load_grid" ) );
EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_grid" ) );
// Removing the task keeps its values aside, so undo brings them back
auto removed = RimWorkflowDefinitionTools::removeTask( renamed.value(), "load_grid" );
ASSERT_TRUE( removed.has_value() );
job.syncTaskInputs( graphTasks( removed.value() ) );
EXPECT_EQ( nullptr, job.taskInput( "load_grid" ) );
job.syncTaskInputs( graphTasks( renamed.value() ) );
EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_grid" ) );
// And through a rename back
job.syncTaskInputs( graphTasks( removed.value() ) );
auto restored = renamed.value();
ASSERT_TRUE( restored.findNode( "load_grid" ) );
restored.findNode( "load_grid" )->name = "load_model";
restored.inputs.remove( "load_grid" );
for ( auto& edge : restored.edges )
{
if ( edge.from == "load_grid" ) edge.from = "load_model";
}
job.syncTaskInputs( graphTasks( removed.value() ), { { "load_grid", "load_model" } } );
job.syncTaskInputs( graphTasks( restored ) );
EXPECT_EQ( "/path/to/grid.roff", pathValue( job, "load_model" ) );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowJobSync, untypedFieldValuesAreLiterals )
{
const QString explicitNull = RimWorkflowSchemaTools::explicitNullKey();
// Text entered before the fields were untyped is read as a literal
RimWorkflowTaskInput input;
input.buildFromSchema( QJsonArray{ QJsonObject{ { "name", "flag" }, { "type", "string" }, { "default", "False" } },
QJsonObject{ { "name", "nothing" }, { "type", "string" }, { "default", "None" } },
QJsonObject{ { "name", "text" }, { "type", "string" }, { "default", "False" } } } );
const QJsonArray untyped{ QJsonObject{ { "name", "flag" }, { "type", "any" } },
QJsonObject{ { "name", "nothing" }, { "type", "any" } },
QJsonObject{ { "name", "text" }, { "type", "string" } } };
QMap<QString, QJsonValue> detached;
input.syncFromSchema( untyped, detached );
const QJsonObject expected{ { "flag", false }, { "nothing", QJsonValue::Null }, { "text", "False" } };
EXPECT_EQ( expected, input.jsonValues() );
// Values, including an entered None, survive a sync
input.syncFromSchema( untyped, detached );
EXPECT_EQ( expected, input.jsonValues() );
// ... and a removal and return of the fields
input.syncFromSchema( QJsonArray{}, detached );
EXPECT_TRUE( input.jsonValues().isEmpty() );
input.syncFromSchema( untyped, detached );
EXPECT_EQ( expected, input.jsonValues() );
// New bindings keep the type of typed values; a model default of None is not a value
RimWorkflowTaskInput typed;
typed.buildFromSchema(
QJsonArray{ QJsonObject{ { "name", "quoted" }, { "type", "any" }, { "default", "true" } },
QJsonObject{ { "name", "number" }, { "type", "any" }, { "default", "1" } },
QJsonObject{ { "name", "flag" }, { "type", "any" }, { "default", true } },
QJsonObject{ { "name", "model_none" }, { "type", "any" }, { "default", QJsonValue::Null } },
QJsonObject{ { "name", "entered_none" }, { "type", "any" }, { "default", QJsonValue::Null }, { explicitNull, true } } } );
const QJsonObject expectedTyped{ { "quoted", "true" }, { "number", "1" }, { "flag", true }, { "entered_none", QJsonValue::Null } };
EXPECT_EQ( expectedTyped, typed.jsonValues() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowJobSync, inputYamlNullIsExplicit )
{
const QJsonObject inputSchema{ { "properties", QJsonObject{ { "value", QJsonObject{ { "default", QJsonValue::Null } } } } } };
const QString explicitNull = RimWorkflowSchemaTools::explicitNullKey();
EXPECT_FALSE( RimWorkflowSchemaTools::configFieldSchema( "value", inputSchema, {}, {} ).value( explicitNull ).toBool() );
EXPECT_TRUE( RimWorkflowSchemaTools::configFieldSchema( "value", inputSchema, {}, QJsonObject{ { "value", QJsonValue::Null } } )
.value( explicitNull )
.toBool() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowEditing, copyPythonModules )
{
QTemporaryDir directory;
ASSERT_TRUE( directory.isValid() );
const QDir root( directory.path() );
auto write = [&root]( const QString& path, const QByteArray& content )
{
QDir().mkpath( QFileInfo( root.absoluteFilePath( path ) ).absolutePath() );
QFile file( root.absoluteFilePath( path ) );
return file.open( QIODevice::WriteOnly ) && file.write( content ) == content.size();
};
ASSERT_TRUE( write( "source/tasks.py", "new" ) );
ASSERT_TRUE( write( "source/workflow.yaml", "" ) );
ASSERT_TRUE( write( "source/helpers/__init__.py", "" ) );
ASSERT_TRUE( write( "source/helpers/math.py", "" ) );
ASSERT_TRUE( write( "source/helpers/__pycache__/math.pyc", "" ) );
ASSERT_TRUE( write( "source/data/values.py", "" ) );
ASSERT_TRUE( write( "target/tasks.py", "existing" ) );
ASSERT_TRUE( RimWorkflow::copyPythonModules( root.absoluteFilePath( "source" ), root.absoluteFilePath( "target" ) ).has_value() );
EXPECT_TRUE( root.exists( "target/helpers/__init__.py" ) );
EXPECT_TRUE( root.exists( "target/helpers/math.py" ) );
EXPECT_FALSE( root.exists( "target/helpers/__pycache__" ) );
EXPECT_FALSE( root.exists( "target/data" ) );
EXPECT_FALSE( root.exists( "target/workflow.yaml" ) );
QFile existing( root.absoluteFilePath( "target/tasks.py" ) );
ASSERT_TRUE( existing.open( QIODevice::ReadOnly ) );
EXPECT_EQ( "existing", existing.readAll() );
// A target that cannot be created is reported
ASSERT_TRUE( write( "blocked", "" ) );
EXPECT_FALSE( RimWorkflow::copyPythonModules( root.absoluteFilePath( "source" ), root.absoluteFilePath( "blocked/target" ) ).has_value() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowEditing, undoRedoAndDirtyState )
{
RimWorkflow workflow;
workflow.setUnsavedDefinition( loadDataDefinition() );
EXPECT_TRUE( workflow.isEditable() );
EXPECT_TRUE( workflow.isDirty() );
EXPECT_FALSE( workflow.canUndo() );
ASSERT_EQ( 1u, workflow.jobs().size() );
RimWorkflowJob* job = workflow.jobs().front();
EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_model" ) );
const int revision = workflow.revision();
auto result = workflow.applyEdit( RimWorkflowDefinitionTools::renameTask( workflow.definition(), "load_model", "load_grid" ),
{ { "load_model", "load_grid" } } );
ASSERT_TRUE( result.has_value() );
EXPECT_GT( workflow.revision(), revision );
EXPECT_TRUE( workflow.canUndo() );
EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_grid" ) );
EXPECT_TRUE( workflow.uiName().endsWith( " *" ) );
using Renames = QMap<QString, QString>;
EXPECT_EQ( ( Renames{ { "load_grid", "load_model" } } ), workflow.undoRenames() );
EXPECT_TRUE( workflow.redoRenames().isEmpty() );
workflow.undo();
EXPECT_EQ( ( Renames{ { "load_model", "load_grid" } } ), workflow.redoRenames() );
EXPECT_TRUE( workflow.definition().findNode( "load_model" ) );
EXPECT_EQ( "/path/to/grid.roff", pathValue( *job, "load_model" ) );
EXPECT_TRUE( workflow.canRedo() );
workflow.redo();
EXPECT_TRUE( workflow.definition().findNode( "load_grid" ) );
EXPECT_FALSE( workflow.canRedo() );
// Failed edits change nothing
const auto before = workflow.definition();
EXPECT_FALSE( workflow.applyEdit( RimWorkflowDefinitionTools::renameTask( before, "load_grid", "bad name" ) ).has_value() );
EXPECT_EQ( before, workflow.definition() );
// Read-only workflows cannot be edited
RimWorkflow readOnly;
auto readOnlyDefinition = loadDataDefinition();
readOnlyDefinition.editable = false;
readOnly.loadFromRegistered( QJsonObject{ { "id", "test.workflow" },
{ "name", "workflow" },
{ "definition", RimWorkflowDefinitionTools::toJson( readOnlyDefinition ) } } );
EXPECT_FALSE( readOnly.isEditable() );
EXPECT_FALSE( readOnly.isDirty() );
EXPECT_FALSE( readOnly.applyEdit( RimWorkflowDefinitionTools::setResultTask( readOnly.definition(), "" ) ).has_value() );
auto runSource = readOnly.prepareRunSource( {} );
ASSERT_TRUE( runSource.has_value() );
EXPECT_EQ( QStringList( { "run", "--registered", "test.workflow" } ), runSource->baseArgs );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowEditing, hiddenAndCollapsedTasksFollowRenamesAndRemovals )
{
RimWorkflow workflow;
workflow.setUnsavedDefinition( loadDataDefinition() );
workflow.setTaskHidden( "connect_to_resinsight", true );
workflow.setTaskHidden( "load_model", true );
workflow.setTaskCollapsed( "load_model", true );
EXPECT_FALSE( workflow.canUndo() );
ASSERT_TRUE( workflow
.applyEdit( RimWorkflowDefinitionTools::renameTask( workflow.definition(), "load_model", "load_grid" ),
{ { "load_model", "load_grid" } } )
.has_value() );
EXPECT_EQ( ( QStringList{ "connect_to_resinsight", "load_grid" } ), workflow.hiddenTasks() );
EXPECT_EQ( ( QStringList{ "load_grid" } ), workflow.collapsedTasks() );
workflow.undo();
EXPECT_EQ( ( QStringList{ "connect_to_resinsight", "load_model" } ), workflow.hiddenTasks() );
EXPECT_TRUE( workflow.isTaskCollapsed( "load_model" ) );
ASSERT_TRUE( workflow.applyEdit( RimWorkflowDefinitionTools::removeTask( workflow.definition(), "load_model" ) ).has_value() );
EXPECT_EQ( ( QStringList{ "connect_to_resinsight" } ), workflow.hiddenTasks() );
EXPECT_TRUE( workflow.collapsedTasks().isEmpty() );
workflow.setTaskHidden( "connect_to_resinsight", false );
EXPECT_TRUE( workflow.hiddenTasks().isEmpty() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowEditing, editabilityOfInstalledAndDuplicatedWorkflows )
{
// An installed workflow is read-only even when its definition is editable,
// but keeps the editable flag so it can be duplicated
RimWorkflow installed;
installed.loadFromRegistered( QJsonObject{ { "id", "test.workflow" },
{ "name", "workflow" },
{ "definition", RimWorkflowDefinitionTools::toJson( loadDataDefinition() ) } } );
EXPECT_FALSE( installed.isEditable() );
EXPECT_TRUE( installed.definition().editable );
EXPECT_FALSE( installed.definition().readOnlyReasons.isEmpty() );
RimWorkflow duplicate;
duplicate.setUnsavedDefinition( installed.definition() );
EXPECT_TRUE( duplicate.isEditable() );
EXPECT_TRUE( duplicate.definition().readOnlyReasons.isEmpty() );
// Unsupported structure (e.g. nested workflows) keeps a duplicate read-only
auto nested = loadDataDefinition();
nested.nodes.front().extra = QJsonObject{ { "workflow", "other" } };
const QStringList structural = RimWorkflowDefinitionTools::structuralReadOnlyReasons( nested );
ASSERT_EQ( 1, structural.size() );
EXPECT_TRUE( structural.front().contains( "workflow" ) );
RimWorkflow nestedWorkflow;
nestedWorkflow.setUnsavedDefinition( nested );
EXPECT_FALSE( nestedWorkflow.isEditable() );
EXPECT_EQ( structural, nestedWorkflow.definition().readOnlyReasons );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowValidationTools, issuesFromHelperResult )
{
const QStringList nodes{ "load_model", "load_model_2", "import" };
const QJsonObject describeError{ { "message", "Missing config" },
{ "task", "load_model" },
{ "issues", QJsonArray{ QJsonObject{ { "field", "path" }, { "code", "missing" } } } } };
auto result =
RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "ok" }, { "describe_error", describeError } }, nodes );
EXPECT_FALSE( result.describeSucceeded );
ASSERT_EQ( 1u, result.issues.size() );
EXPECT_EQ( "load_model", result.issues[0].task );
EXPECT_EQ( "path", result.issues[0].field );
EXPECT_EQ( RimWorkflowIssue::Severity::Error, result.issues[0].severity );
// The task is found from a quoted name in the message, preferring the longest match
result =
RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "invalid" },
{ "error",
QJsonObject{ { "message", "Task 'load_model_2' has a bad input" } } } },
nodes );
ASSERT_EQ( 1u, result.issues.size() );
EXPECT_EQ( "load_model_2", result.issues[0].task );
// Unknown tasks go to the workflow
result = RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "invalid" },
{ "error",
QJsonObject{ { "message", "Broken" }, { "task", "other" } } } },
nodes );
ASSERT_EQ( 1u, result.issues.size() );
EXPECT_TRUE( result.issues[0].task.isEmpty() );
// Missing config values are warnings
const QJsonObject describe{
{ "tasks", QJsonArray{ QJsonObject{ { "name", "import" }, { "missing_config_fields", QJsonArray{ "paths" } } } } } };
result = RimWorkflowValidationTools::issuesFromHelperResult( QJsonObject{ { "status", "ok" }, { "describe", describe } }, nodes );
EXPECT_TRUE( result.describeSucceeded );
ASSERT_EQ( 1u, result.issues.size() );
EXPECT_EQ( RimWorkflowIssue::Severity::Warning, result.issues[0].severity );
EXPECT_EQ( "paths", result.issues[0].field );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowHelperProcess, invalidResultKeepsTaskAndField )
{
const QByteArray invalid = R"({"status": "invalid", "error": {"message": "Bad value", "task": "load_model", "field": "path"}})";
const auto result = RimWorkflowHelperProcess::invalidResult( invalid + "\n" );
ASSERT_TRUE( result.has_value() );
const auto validation = RimWorkflowValidationTools::issuesFromHelperResult( *result, { "load_model" } );
EXPECT_FALSE( validation.describeSucceeded );
ASSERT_EQ( 1u, validation.issues.size() );
EXPECT_EQ( "load_model", validation.issues[0].task );
EXPECT_EQ( "path", validation.issues[0].field );
// parseOutput() reduces the same output to a message
const auto parsed = RimWorkflowHelperProcess::parseOutput( 2, invalid, {} );
ASSERT_FALSE( parsed.has_value() );
EXPECT_TRUE( parsed.error().startsWith( "Bad value" ) );
EXPECT_FALSE( RimWorkflowHelperProcess::invalidResult( R"({"status": "ok"})" ).has_value() );
EXPECT_FALSE( RimWorkflowHelperProcess::invalidResult( "Traceback ..." ).has_value() );
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowValidationTools, issuesWithJobValues )
{
const QJsonObject missingCase{ { "task", "select" },
{ "field", "case" },
{ "severity", "warning" },
{ "message", RimWorkflowValidationTools::missingValueMessage( "case" ) } };
const QJsonObject otherIssue{ { "task", "select" }, { "field", "" }, { "severity", "error" }, { "message", "Broken" } };
// A value in the job removes the stale warning from describe
QJsonArray issues = RimWorkflowValidationTools::issuesWithJobValues( "select", { missingCase, otherIssue }, { { "case", true } } );
ASSERT_EQ( 1, issues.size() );
EXPECT_EQ( "Broken", issues[0].toObject().value( "message" ).toString() );
// A missing value in the job gives one warning, even if describe also reported it
issues = RimWorkflowValidationTools::issuesWithJobValues( "select", { missingCase }, { { "case", false } } );
ASSERT_EQ( 1, issues.size() );
EXPECT_EQ( "case", issues[0].toObject().value( "field" ).toString() );
EXPECT_EQ( "warning", issues[0].toObject().value( "severity" ).toString() );
// Fields the job does not bind keep the warnings from describe
issues = RimWorkflowValidationTools::issuesWithJobValues( "select", { missingCase }, {} );
EXPECT_EQ( 1, issues.size() );
}