///////////////////////////////////////////////////////////////////////////////// // // 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 // for more details. // ///////////////////////////////////////////////////////////////////////////////// #include "gtest/gtest.h" #include "RiaTestDataDirectory.h" #include "RimWorkflowDefinitionTools.h" #include "RimWorkflowPortCompatibility.h" #include "RimWorkflowTaskCatalog.h" #include #include #include #include // // 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& 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_EQ( ":/codicons/symbol-string.svg", exportTask.value( "output_icons" ).toObject().value( "export_file" ).toString() ); 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]" } } } } } }, {} ) ); // Plain values get an icon for their kind; models without a ResInsight object get none auto valueIcon = []( const QJsonObject& schema ) { return RimWorkflowPortCompatibility::iconResource( schema, {} ); }; EXPECT_EQ( ":/codicons/symbol-string.svg", valueIcon( { { "type", "string" } } ) ); EXPECT_EQ( ":/codicons/symbol-numeric.svg", valueIcon( { { "type", "integer" } } ) ); EXPECT_EQ( ":/codicons/symbol-numeric.svg", valueIcon( { { "type", "number" } } ) ); EXPECT_EQ( ":/codicons/calendar.svg", valueIcon( { { "type", "string" }, { "format", "date" } } ) ); EXPECT_EQ( ":/codicons/file.svg", valueIcon( { { "type", "string" }, { "format", "path" } } ) ); EXPECT_EQ( ":/codicons/folder.svg", valueIcon( { { "type", "string" }, { "format", "directory-path" } } ) ); EXPECT_EQ( ":/codicons/symbol-numeric.svg", valueIcon( { { "anyOf", QJsonArray{ QJsonObject{ { "type", "number" } }, QJsonObject{ { "type", "null" } } } } } ) ); EXPECT_EQ( ":/codicons/symbol-string.svg", valueIcon( { { "type", "array" }, { "items", QJsonObject{ { "type", "string" } } } } ) ); EXPECT_EQ( ":/codicons/check.svg", valueIcon( { { "type", "boolean" } } ) ); EXPECT_TRUE( valueIcon( { { "type", "object" }, { "properties", QJsonObject{ { "a", QJsonObject{ { "type", "string" } } } } } } ).isEmpty() ); // 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>{ { "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 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" ) ); }