Files
ResInsight/ApplicationLibCode/UnitTests/RimWorkflowDefinition-Test.cpp
T
Kristian Bendiksen 61c2db898f Workflows: Support taskmaestro map and collect in the editor
Collect: list and dict inputs accept several connections of their item
type. Members are labelled with their position or key, and can be
reordered or renamed from the connection context menu.

Map: Map Over / Edit Map / Remove Map on tasks. A mapped task is drawn
as a stack with a "map over" subtitle, its items are edited in the
property view (JSON, or selected cases, well paths or views), and
per-item progress is shown during a run.

Workflows using map or collect are no longer read-only. The run status
no longer shows "Completed" after a successful run.
2026-10-21 11:20:02 +02:00

581 lines
32 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 "RimWorkflowDefinitionTools.h"
#include "RimWorkflowPortCompatibility.h"
#include "RimWorkflowTaskCatalog.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
//
// Fixtures in TestData/RimWorkflowDefinition are produced by the Python helper (run from
// GrpcInterface/Python, with absolute paths replaced by `test_data/taskmaestro_workflows`):
// python -m rips.taskmaestro_helper load rips/tests/test_data/taskmaestro_workflows/resinsight_completions --describe > completions.json
// python -m rips.taskmaestro_helper load rips/tests/test_data/taskmaestro_workflows/resinsight_load_data --describe > load_data.json
// python -m rips.taskmaestro_helper catalog > catalog.json
//
namespace
{
QJsonObject fixture( const QString& name )
{
QFile file( QString( "%1/RimWorkflowDefinition/%2.json" ).arg( TEST_DATA_DIR ).arg( name ) );
if ( !file.open( QIODevice::ReadOnly ) ) return {};
return QJsonDocument::fromJson( file.readAll() ).object();
}
RimWorkflowDefinition definitionFixture( const QString& name )
{
auto definition = RimWorkflowDefinitionTools::fromJson( fixture( name ) );
EXPECT_TRUE( definition.has_value() );
return definition.value_or( RimWorkflowDefinition() );
}
RimWorkflowTaskCatalog catalogFixture()
{
return RimWorkflowTaskCatalog::fromJson( fixture( "catalog" ) );
}
QJsonObject taskByName( const QJsonObject& graph, const QString& name )
{
for ( const QJsonValue& task : graph.value( "tasks" ).toArray() )
{
if ( task.toObject().value( "name" ).toString() == name ) return task.toObject();
}
return {};
}
RimWorkflowDefinition edited( const RimWorkflowDefinitionTools::EditResult& result )
{
EXPECT_TRUE( result.has_value() ) << ( result ? "" : result.error().toStdString() );
return result.value_or( RimWorkflowDefinition() );
}
bool hasEdge( const RimWorkflowDefinition& definition, const RimWorkflowDefinitionEdge& edge )
{
return std::find( definition.edges.begin(), definition.edges.end(), edge ) != definition.edges.end();
}
QStringList issueMessages( const std::vector<RimWorkflowIssue>& issues )
{
QStringList messages;
for ( const auto& issue : issues )
messages.append( QString( "%1.%2: %3" ).arg( issue.task, issue.field, issue.message ) );
return messages;
}
} // namespace
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinition, fromJson )
{
const auto definition = definitionFixture( "completions" );
EXPECT_EQ( "resinsight_completions", definition.name );
EXPECT_TRUE( definition.editable );
ASSERT_EQ( 7u, definition.nodes.size() );
EXPECT_EQ( 13u, definition.edges.size() );
EXPECT_TRUE( definition.resultTask.isEmpty() );
const auto* addPerf = definition.findNode( "add_perf_1" );
ASSERT_TRUE( addPerf );
EXPECT_EQ( "resinsight.add_perforation", addPerf->taskId );
EXPECT_EQ( QStringList( { "event_date", "start_md", "end_md" } ), addPerf->configFields );
EXPECT_EQ( addPerf->configFields, addPerf->explicitConfigFields );
EXPECT_FALSE( definition.describe.isEmpty() );
EXPECT_FALSE( RimWorkflowDefinitionTools::fromJson( QJsonObject{ { "format", 2 }, { "nodes", QJsonArray() } } ).has_value() );
EXPECT_FALSE( RimWorkflowDefinitionTools::fromJson( QJsonObject{ { "name", "x" } } ).has_value() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinition, jsonRoundTrip )
{
for ( const QString& name : { "completions", "load_data" } )
{
const auto definition = definitionFixture( name );
const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) );
ASSERT_TRUE( roundTrip.has_value() );
EXPECT_EQ( definition, roundTrip.value() ) << name.toStdString();
}
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinition, normalizeKeepsLoadedExamples )
{
for ( const QString& name : { "completions", "load_data" } )
{
const auto definition = definitionFixture( name );
const auto normalized = RimWorkflowDefinitionTools::normalize( definition );
for ( size_t i = 0; i < definition.nodes.size(); ++i )
{
EXPECT_EQ( definition.nodes[i].configFields, normalized.nodes[i].configFields ) << definition.nodes[i].name.toStdString();
}
EXPECT_EQ( normalized, RimWorkflowDefinitionTools::normalize( normalized ) );
EXPECT_TRUE( RimWorkflowDefinitionTools::validate( definition ).empty() )
<< issueMessages( RimWorkflowDefinitionTools::validate( definition ) ).join( "\n" ).toStdString();
}
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinition, graphFromDefinition )
{
const auto definition = definitionFixture( "completions" );
const auto graph = RimWorkflowDefinitionTools::graphFromDefinition( definition, true );
EXPECT_EQ( "resinsight_completions", graph.value( "name" ).toString() );
EXPECT_TRUE( graph.value( "editable" ).toBool() );
EXPECT_EQ( "export_completions", graph.value( "result_task" ).toString() );
EXPECT_EQ( 7, graph.value( "tasks" ).toArray().size() );
EXPECT_EQ( 13, graph.value( "edges" ).toArray().size() );
EXPECT_TRUE( graph.value( "issues" ).toArray().isEmpty() );
const QJsonObject exportTask = taskByName( graph, "export_completions" );
EXPECT_TRUE( exportTask.value( "is_result" ).toBool() );
EXPECT_EQ( 2, exportTask.value( "config_fields" ).toArray().size() );
EXPECT_EQ( QJsonArray( { "export_file", "well_path_names" } ), exportTask.value( "outputs" ).toArray() );
// The whole input port comes first, followed by the fields
const QJsonArray ports = exportTask.value( "input_ports" ).toArray();
ASSERT_EQ( 7, ports.size() );
EXPECT_EQ( "", ports[0].toObject().value( "name" ).toString() );
EXPECT_EQ( "resinsight", ports[1].toObject().value( "name" ).toString() );
EXPECT_TRUE( ports[1].toObject().value( "wired" ).toBool() );
// Type names for labelling the ports
EXPECT_EQ( "RipsInstance", exportTask.value( "input_types" ).toObject().value( "resinsight" ).toString() );
EXPECT_EQ( "str", exportTask.value( "output_types" ).toObject().value( "export_file" ).toString() );
// Icons of ResInsight objects, found through `ObjectModel[Class]` and rips bases
const QJsonObject exportIcons = exportTask.value( "input_icons" ).toObject();
EXPECT_EQ( ":/AppLogo48x48.png", exportIcons.value( "resinsight" ).toString() );
EXPECT_FALSE( exportTask.value( "output_icons" ).toObject().contains( "export_file" ) );
EXPECT_EQ( ":/Case48x48.png", taskByName( graph, "select_eclipse_case" ).value( "output_icons" ).toObject().value( "" ).toString() );
EXPECT_EQ( ":/Well.svg", taskByName( graph, "select_well_path_1" ).value( "output_icons" ).toObject().value( "" ).toString() );
EXPECT_EQ( ":/Case48x48.png",
RimWorkflowPortCompatibility::iconResource( QJsonObject{ { "x-ri-python-type", "rips.generated.generated_classes.Reservoir" } },
{} ) );
EXPECT_EQ( ":/3DView16x16.png",
RimWorkflowPortCompatibility::iconResource( QJsonObject{ { "anyOf",
QJsonArray{ QJsonObject{ { "type", "null" } },
QJsonObject{ { "x-ri-python-bases",
QJsonArray{ "pkg.GridView",
"taskmaestro.object_model."
"ObjectModel["
"View]" } } } } } },
{} ) );
// The connect task's opaque `value` is not a port of its own
const QJsonObject connect = taskByName( graph, "connect_to_resinsight" );
EXPECT_TRUE( connect.value( "outputs" ).toArray().isEmpty() );
EXPECT_FALSE( connect.value( "accepts_input" ).toBool() );
EXPECT_FALSE( RimWorkflowDefinitionTools::graphFromDefinition( definition, false ).value( "editable" ).toBool() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowPortCompatibility, fieldConnections )
{
const auto definition = definitionFixture( "completions" );
using RimWorkflowPortCompatibility::canConnect;
EXPECT_TRUE( canConnect( definition, "connect_to_resinsight", "", "select_eclipse_case", "resinsight" ) );
EXPECT_TRUE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "grid_case" ) );
// A GridCase is not a RipsInstance
EXPECT_FALSE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "resinsight" ) );
// A perforation is not a well path
EXPECT_FALSE( canConnect( definition, "add_perf_1", "", "add_perf_2", "well_path" ) );
// Cycles and self-loops
EXPECT_FALSE( canConnect( definition, "export_completions", "", "select_eclipse_case", "case" ) );
EXPECT_FALSE( canConnect( definition, "add_perf_1", "", "add_perf_1", "well_path" ) );
// Unknown ports and tasks
EXPECT_FALSE( canConnect( definition, "select_eclipse_case", "", "select_well_path_1", "no_such_input" ) );
EXPECT_FALSE( canConnect( definition, "no_such_task", "", "select_well_path_1", "grid_case" ) );
// A task cannot take both its whole input and fields from other tasks
EXPECT_FALSE( canConnect( definition, "select_well_path_1", "", "add_perf_1", "" ) );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowPortCompatibility, wholeConnections )
{
const auto catalog = catalogFixture();
auto definition = RimWorkflowDefinitionTools::createEmpty( "test" );
for ( const QString& taskId : { "resinsight.connect", "resinsight.load_model", "resinsight.close_project", "resinsight.exit" } )
{
definition.taskTypes.insert( taskId, catalog.taskType( taskId ) );
definition.nodes.push_back( { .name = taskId.mid( 11 ), .taskId = taskId } );
}
using RimWorkflowPortCompatibility::canConnect;
// RipsInstance -> close_project (RipsInstance) and exit (RipsInstance)
EXPECT_TRUE( canConnect( definition, "connect", "", "close_project", "" ) );
EXPECT_TRUE( canConnect( definition, "close_project", "", "exit", "" ) );
// GridCase -> RipsInstance is not compatible
EXPECT_FALSE( canConnect( definition, "load_model", "", "close_project", "" ) );
// Whole RipsInstance into a field input
EXPECT_TRUE( canConnect( definition, "connect", "", "load_model", "resinsight" ) );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, addTaskWiresResInsight )
{
const auto catalog = catalogFixture();
auto definition = RimWorkflowDefinitionTools::createEmpty( "new_workflow" );
QString added;
definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.load_model", &added ) );
EXPECT_EQ( "load_model", added );
ASSERT_EQ( 2u, definition.nodes.size() );
EXPECT_EQ( "connect_to_resinsight", definition.nodes[0].name );
EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "load_model", .input = "resinsight" } ) );
EXPECT_EQ( QStringList( { "path" } ), definition.findNode( "load_model" )->configFields );
EXPECT_TRUE( definition.inputs.contains( "load_model" ) );
EXPECT_TRUE( definition.taskTypes.contains( "resinsight.load_model" ) );
// A second instance gets a unique name and reuses the connect task
definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.load_model", &added ) );
EXPECT_EQ( "load_model_2", added );
EXPECT_EQ( 3u, definition.nodes.size() );
EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "load_model_2", .input = "resinsight" } ) );
// Two end tasks and no result task
auto issues = RimWorkflowDefinitionTools::validate( definition );
ASSERT_EQ( 1u, issues.size() ) << issueMessages( issues ).join( "\n" ).toStdString();
EXPECT_TRUE( issues[0].task.isEmpty() );
definition = edited( RimWorkflowDefinitionTools::setResultTask( definition, "load_model_2" ) );
EXPECT_TRUE( RimWorkflowDefinitionTools::validate( definition ).empty() );
// The exit task takes the whole RipsInstance
definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.exit", &added ) );
EXPECT_EQ( "exit_resinsight", added );
EXPECT_TRUE( hasEdge( definition, { .from = "connect_to_resinsight", .to = "exit_resinsight" } ) );
EXPECT_TRUE( definition.findNode( "exit_resinsight" )->configFields.isEmpty() );
EXPECT_FALSE( RimWorkflowDefinitionTools::addTask( definition, catalog, "no.such.task" ).has_value() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, connectionsUpdateConfigFields )
{
auto definition = definitionFixture( "completions" );
const RimWorkflowDefinitionEdge gridCase{ .from = "select_eclipse_case", .to = "select_well_path_1", .input = "grid_case" };
// A disconnected required input that can be entered in ResInsight becomes a config field
definition = edited( RimWorkflowDefinitionTools::disconnect( definition, gridCase ) );
EXPECT_EQ( QStringList( { "well_path", "grid_case" } ), definition.findNode( "select_well_path_1" )->configFields );
EXPECT_FALSE( RimWorkflowDefinitionTools::disconnect( definition, gridCase ).has_value() );
definition = edited( RimWorkflowDefinitionTools::connect( definition, gridCase ) );
EXPECT_EQ( QStringList( { "well_path" } ), definition.findNode( "select_well_path_1" )->configFields );
// Connecting an input that is already connected replaces the connection
definition = edited(
RimWorkflowDefinitionTools::connect( definition, { .from = "add_perf_2", .to = "export_completions", .input = "perforation_1" } ) );
EXPECT_FALSE( hasEdge( definition, { .from = "add_perf_1", .to = "export_completions", .input = "perforation_1" } ) );
EXPECT_EQ( 13u, definition.edges.size() );
// add_perf_1 is now an end task, and the perforation input of export is not configurable
auto issues = RimWorkflowDefinitionTools::validate( definition );
EXPECT_EQ( 1u, issues.size() ) << issueMessages( issues ).join( "\n" ).toStdString();
definition = edited(
RimWorkflowDefinitionTools::disconnect( definition, { .from = "add_perf_2", .to = "export_completions", .input = "perforation_1" } ) );
issues = RimWorkflowDefinitionTools::validate( definition );
bool mustConnect = false;
for ( const auto& issue : issues )
mustConnect = mustConnect || ( issue.task == "export_completions" && issue.field == "perforation_1" );
EXPECT_TRUE( mustConnect ) << issueMessages( issues ).join( "\n" ).toStdString();
const auto graph = RimWorkflowDefinitionTools::graphFromDefinition( definition, true );
EXPECT_FALSE( taskByName( graph, "export_completions" ).value( "issues" ).toArray().isEmpty() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, removeAndRename )
{
auto definition = definitionFixture( "load_data" );
EXPECT_EQ( "import_grid_property", definition.resultTask );
definition.inputs["load_model"] = QJsonObject{ { "path", "/data/grid.roff" } };
definition = edited( RimWorkflowDefinitionTools::renameTask( definition, "load_model", "load_grid" ) );
EXPECT_TRUE( definition.findNode( "load_grid" ) );
EXPECT_FALSE( definition.inputs.contains( "load_model" ) );
EXPECT_EQ( "/data/grid.roff", definition.inputs["load_grid"].toObject().value( "path" ).toString() );
EXPECT_TRUE( hasEdge( definition, { .from = "load_grid", .to = "import_grid_property", .input = "grid_case" } ) );
definition = edited( RimWorkflowDefinitionTools::renameTask( definition, "import_grid_property", "import_props" ) );
EXPECT_EQ( "import_props", definition.resultTask );
EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "import_props" ).has_value() );
EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "2bad" ).has_value() );
EXPECT_FALSE( RimWorkflowDefinitionTools::renameTask( definition, "load_grid", "with space" ).has_value() );
EXPECT_FALSE( RimWorkflowDefinitionTools::renameWorkflow( definition, "bad-name" ).has_value() );
EXPECT_EQ( "renamed", edited( RimWorkflowDefinitionTools::renameWorkflow( definition, "renamed" ) ).name );
definition = edited( RimWorkflowDefinitionTools::removeTask( definition, "import_props" ) );
EXPECT_TRUE( definition.resultTask.isEmpty() );
EXPECT_FALSE( definition.inputs.contains( "import_props" ) );
for ( const auto& edge : definition.edges )
EXPECT_NE( "import_props", edge.to );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, optionalInputs )
{
const auto catalog = catalogFixture();
auto definition = RimWorkflowDefinitionTools::createEmpty( "views" );
QString added;
definition = edited( RimWorkflowDefinitionTools::addTask( definition, catalog, "resinsight.configure_grid_view", &added ) );
EXPECT_FALSE( definition.findNode( added )->configFields.contains( "time_step" ) );
definition = edited( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "time_step", true ) );
EXPECT_TRUE( definition.findNode( added )->configFields.contains( "time_step" ) );
definition = edited( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "time_step", false ) );
EXPECT_FALSE( definition.findNode( added )->configFields.contains( "time_step" ) );
EXPECT_FALSE( RimWorkflowDefinitionTools::setOptionalInputConfigured( definition, added, "result_type", true ).has_value() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, readOnlyDefinitionsAreNotEdited )
{
auto definition = definitionFixture( "load_data" );
definition.editable = false;
EXPECT_FALSE( RimWorkflowDefinitionTools::removeTask( definition, "load_model" ).has_value() );
EXPECT_FALSE( RimWorkflowDefinitionTools::renameWorkflow( definition, "other" ).has_value() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, names )
{
EXPECT_TRUE( RimWorkflowDefinitionTools::isIdentifier( "load_model_2" ) );
EXPECT_FALSE( RimWorkflowDefinitionTools::isIdentifier( "2load" ) );
EXPECT_FALSE( RimWorkflowDefinitionTools::isIdentifier( "" ) );
EXPECT_EQ( "resinsight_load_model", RimWorkflowDefinitionTools::identifierFrom( "resinsight.load-model" ) );
EXPECT_EQ( "_3d", RimWorkflowDefinitionTools::identifierFrom( "3d" ) );
}
namespace
{
QJsonObject numberModel( const QString& title, const QString& field, const QJsonObject& fieldSchema )
{
return QJsonObject{ { "type", "object" },
{ "title", title },
{ "properties", QJsonObject{ { field, fieldSchema } } },
{ "required", QJsonArray{ field } } };
}
// produce(name: str) -> value: float; scale(name: str, factor: float) -> value: float;
// sum_list(values: list[float]); sum_dict(values: dict[str, float])
RimWorkflowDefinition collectDefinition()
{
const QJsonObject number{ { "type", "number" } };
const QJsonObject output = numberModel( "Value", "value", number );
const QJsonObject nameInput{ { "type", "object" },
{ "title", "Named" },
{ "properties", QJsonObject{ { "name", QJsonObject{ { "type", "string" } } }, { "factor", number } } },
{ "required", QJsonArray{ "name", "factor" } } };
auto definition = RimWorkflowDefinitionTools::createEmpty( "collect" );
definition.taskTypes.insert( "t.produce", QJsonObject{ { "name", "produce" }, { "input_schema", nameInput }, { "output_schema", output } } );
definition.taskTypes.insert( "t.sum_list",
QJsonObject{ { "name", "sum_list" },
{ "input_schema",
numberModel( "ListInput", "values", QJsonObject{ { "type", "array" }, { "items", number } } ) },
{ "output_schema", output } } );
definition.taskTypes.insert( "t.sum_dict",
QJsonObject{ { "name", "sum_dict" },
{ "input_schema",
numberModel( "DictInput",
"values",
QJsonObject{ { "type", "object" }, { "additionalProperties", number } } ) },
{ "output_schema", output } } );
for ( const auto& [name, taskId] : std::vector<std::pair<QString, QString>>{ { "a", "t.produce" },
{ "b", "t.produce" },
{ "c", "t.produce" },
{ "sum_list", "t.sum_list" },
{ "sum_dict", "t.sum_dict" } } )
{
definition.nodes.push_back( { .name = name, .taskId = taskId } );
definition.inputs.insert( name, QJsonObject() );
}
definition.taskTypes.insert( "t.gather",
QJsonObject{ { "name", "gather" },
{ "input_schema",
QJsonObject{ { "type", "object" },
{ "title", "GatherInput" },
{ "properties",
QJsonObject{ { "results",
QJsonObject{ { "type", "object" },
{ "additionalProperties",
QJsonObject{ { "$ref", "#/$defs/Value" } } } } } } },
{ "required", QJsonArray{ "results" } },
{ "$defs", QJsonObject{ { "Value", output } } } } },
{ "output_schema", output } } );
definition.nodes.push_back( { .name = "gather", .taskId = "t.gather" } );
definition.inputs.insert( "gather", QJsonObject() );
return RimWorkflowDefinitionTools::normalize( definition );
}
QStringList taskIssues( const RimWorkflowDefinition& definition, const QString& task )
{
std::vector<RimWorkflowIssue> issues;
for ( const auto& issue : RimWorkflowDefinitionTools::validate( definition ) )
{
if ( issue.task == task ) issues.push_back( issue );
}
return issueMessages( issues );
}
} // namespace
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, collectIntoList )
{
using Collect = RimWorkflowDefinitionEdge::Collect;
auto definition = collectDefinition();
definition =
edited( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .output = "value", .to = "sum_list", .input = "values" } ) );
definition =
edited( RimWorkflowDefinitionTools::connect( definition, { .from = "b", .output = "value", .to = "sum_list", .input = "values" } ) );
ASSERT_EQ( 2u, definition.incomingEdges( "sum_list" ).size() );
EXPECT_EQ( Collect::List, definition.incomingEdges( "sum_list" )[0].collect );
EXPECT_EQ( "a", definition.incomingEdges( "sum_list" )[0].from );
EXPECT_TRUE( definition.findNode( "sum_list" )->configFields.isEmpty() );
EXPECT_TRUE( taskIssues( definition, "sum_list" ).isEmpty() ) << taskIssues( definition, "sum_list" ).join( "\n" ).toStdString();
// Collecting the same output twice is rejected
EXPECT_FALSE( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .output = "value", .to = "sum_list", .input = "values" } ) );
// Reorder the members
const RimWorkflowDefinitionEdge second{ .from = "b", .output = "value", .to = "sum_list", .input = "values" };
definition = edited( RimWorkflowDefinitionTools::moveCollectMember( definition, second, -1 ) );
EXPECT_EQ( "b", definition.incomingEdges( "sum_list" )[0].from );
EXPECT_FALSE( RimWorkflowDefinitionTools::moveCollectMember( definition, second, -1 ) );
// Index labels in the graph
const QJsonArray edges = RimWorkflowDefinitionTools::graphFromDefinition( definition, true ).value( "edges" ).toArray();
QStringList labels;
for ( const QJsonValue& edge : edges )
labels.append( edge.toObject().value( "from" ).toString() + edge.toObject().value( "label" ).toString() );
EXPECT_TRUE( labels.contains( "b[0]" ) );
EXPECT_TRUE( labels.contains( "a[1]" ) );
// Disconnecting ignores how the member is collected
definition = edited(
RimWorkflowDefinitionTools::disconnect( definition, { .from = "a", .output = "value", .to = "sum_list", .input = "values" } ) );
EXPECT_EQ( 1u, definition.incomingEdges( "sum_list" ).size() );
// JSON round trip keeps the collected edges
const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) );
ASSERT_TRUE( roundTrip.has_value() );
EXPECT_EQ( definition.edges, roundTrip->edges );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, collectIntoDict )
{
using Collect = RimWorkflowDefinitionEdge::Collect;
auto definition = collectDefinition();
definition =
edited( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .output = "value", .to = "sum_dict", .input = "values" } ) );
definition =
edited( RimWorkflowDefinitionTools::connect( definition, { .from = "b", .output = "value", .to = "sum_dict", .input = "values" } ) );
const auto incoming = definition.incomingEdges( "sum_dict" );
ASSERT_EQ( 2u, incoming.size() );
EXPECT_EQ( Collect::Dict, incoming[0].collect );
EXPECT_EQ( "a_value", incoming[0].key );
EXPECT_EQ( "b_value", incoming[1].key );
const RimWorkflowDefinitionEdge first{ .from = "a", .output = "value", .to = "sum_dict", .input = "values" };
EXPECT_FALSE( RimWorkflowDefinitionTools::setCollectKey( definition, first, "b_value" ) );
EXPECT_FALSE( RimWorkflowDefinitionTools::setCollectKey( definition, first, "" ) );
definition = edited( RimWorkflowDefinitionTools::setCollectKey( definition, first, "low" ) );
EXPECT_EQ( "low", definition.incomingEdges( "sum_dict" )[0].key );
EXPECT_TRUE( taskIssues( definition, "sum_dict" ).isEmpty() ) << taskIssues( definition, "sum_dict" ).join( "\n" ).toStdString();
const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) );
ASSERT_TRUE( roundTrip.has_value() );
EXPECT_EQ( definition.edges, roundTrip->edges );
// Duplicate keys are reported
definition.edges[1].key = "low";
EXPECT_FALSE( taskIssues( definition, "sum_dict" ).isEmpty() );
}
//--------------------------------------------------------------------------------------------------
TEST( RimWorkflowDefinitionEdit, mapTask )
{
auto definition = collectDefinition();
// The key input must accept a string
EXPECT_FALSE(
RimWorkflowDefinitionTools::setTaskMap( definition, "a", RimWorkflowTaskMap{ .over = "items", .keyAs = "factor", .valueAs = "name" } ) );
EXPECT_FALSE(
RimWorkflowDefinitionTools::setTaskMap( definition, "a", RimWorkflowTaskMap{ .over = "name", .keyAs = "name", .valueAs = "factor" } ) );
EXPECT_FALSE(
RimWorkflowDefinitionTools::setTaskMap( definition, "a", RimWorkflowTaskMap{ .over = "items", .keyAs = "name", .valueAs = "name" } ) );
const RimWorkflowTaskMap map{ .over = "items", .keyAs = "name", .valueAs = "factor" };
definition = edited( RimWorkflowDefinitionTools::setTaskMap( definition, "a", map ) );
ASSERT_TRUE( definition.findNode( "a" )->map.has_value() );
EXPECT_TRUE( definition.findNode( "a" )->configFields.isEmpty() );
// The mapped output is a dict[str, Value]: it feeds a dict[str, Value] input directly
const QJsonObject mappedType = definition.taskTypeForNode( "a" );
EXPECT_EQ( "dict[str, Value]", RimWorkflowPortCompatibility::outputPorts( mappedType ).front().typeName );
EXPECT_FALSE( RimWorkflowPortCompatibility::canConnect( definition, "a", "", "sum_dict", "values" ) );
EXPECT_FALSE( RimWorkflowPortCompatibility::canConnect( definition, "a", "", "sum_list", "values" ) );
definition = edited( RimWorkflowDefinitionTools::connect( definition, { .from = "a", .to = "gather", .input = "results" } ) );
EXPECT_FALSE( definition.incomingEdges( "gather" ).front().isCollected() );
// A mapped task takes its inputs field by field
EXPECT_FALSE( RimWorkflowPortCompatibility::canConnect( definition, "b", "", "a", "" ) );
// The mapping is a config value shown as an input port
const QJsonObject task = taskByName( RimWorkflowDefinitionTools::graphFromDefinition( definition, true ), "a" );
EXPECT_EQ( "items", task.value( "map" ).toObject().value( "over" ).toString() );
EXPECT_EQ( "dict[str, float]", task.value( "input_types" ).toObject().value( "items" ).toString() );
bool hasOverField = false;
for ( const QJsonValue& field : task.value( "config_fields" ).toArray() )
{
if ( field.toObject().value( "name" ).toString() == "items" )
{
hasOverField = field.toObject().value( "map_over" ).toBool();
EXPECT_EQ( "number", field.toObject().value( "value_type" ).toString() );
}
}
EXPECT_TRUE( hasOverField );
// JSON round trip
const auto roundTrip = RimWorkflowDefinitionTools::fromJson( RimWorkflowDefinitionTools::toJson( definition ) );
ASSERT_TRUE( roundTrip.has_value() );
EXPECT_EQ( definition, roundTrip.value() );
// Removing the map restores the inputs and drops the mapping value
definition.inputs.insert( "a", QJsonObject{ { "items", QJsonObject{ { "x", 1.0 } } } } );
definition = edited( RimWorkflowDefinitionTools::setTaskMap( definition, "a", std::nullopt ) );
EXPECT_FALSE( definition.findNode( "a" )->map.has_value() );
EXPECT_EQ( QStringList( { "name", "factor" } ), definition.findNode( "a" )->configFields );
EXPECT_FALSE( definition.inputs.value( "a" ).toObject().contains( "items" ) );
}