Merge pull request #14473 from mvdbeek/change_datatype_problems

[22.05] Fix effective datatype in workflow editor node terminals
This commit is contained in:
Dannon
2022-08-19 06:32:49 -04:00
committed by GitHub
12 changed files with 220 additions and 117 deletions
@@ -70,6 +70,7 @@
v-for="output in outputs"
:key="output.name"
:output="output"
:post-job-actions="postJobActions"
:get-node="getNode"
:get-manager="getManager"
@onAdd="onAddOutput"
@@ -61,10 +61,11 @@ export default {
oldTerminal.destroyInvalidConnections();
} else {
// create new terminal, connect like old terminal, destroy old terminal
// this might be a little buggy, the terminals and connectors should be vue components
this.$emit("onRemove", this.input);
const newTerminal = this.createTerminal(newInput);
newTerminal.connectors = this.terminal.connectors.map((c) => {
return new Connector(this.getManager(), c.outputHandle, newTerminal);
return new Connector(this.getManager().canvasManager, c.outputHandle, newTerminal);
});
newTerminal.destroyInvalidConnections();
this.terminal = newTerminal;
@@ -34,6 +34,10 @@ export default {
type: Function,
required: true,
},
postJobActions: {
type: Object,
required: true,
},
},
data() {
return {
@@ -46,12 +50,8 @@ export default {
return `node-${node.id}-output-${this.output.name}`;
},
label() {
let extensions = this.output.extensions || this.output.type || "unspecified";
if (Array.isArray(extensions)) {
extensions = extensions.join(", ");
}
const activeLabel = this.output.activeLabel || this.output.label || this.output.name;
return `${activeLabel} (${extensions})`;
return `${activeLabel} (${this.extensions.join(", ")})`;
},
activeClass() {
return this.output.activeOutput && "mark-terminal-active";
@@ -67,6 +67,19 @@ export default {
}
return cls;
},
forcedExtension() {
const changeDatatype =
this.postJobActions[`ChangeDatatypeAction${this.output.label}`] ||
this.postJobActions[`ChangeDatatypeAction${this.output.name}`];
return changeDatatype?.action_arguments.newtype;
},
extensions() {
let extensions = this.forcedExtension || this.output.extensions || this.output.type || "unspecified";
if (!Array.isArray(extensions)) {
extensions = [extensions];
}
return extensions;
},
},
watch: {
label() {
@@ -78,14 +91,15 @@ export default {
output(newOutput) {
const oldTerminal = this.terminal;
if (oldTerminal instanceof this.terminalClassForOutput(newOutput)) {
oldTerminal.update(newOutput);
oldTerminal.update({ ...newOutput, extensions: this.extensions });
oldTerminal.destroyInvalidConnections();
} else {
// create new terminal, connect like old terminal, destroy old terminal
// this might be a little buggy, we should replace this with proper vue components
this.$emit("onRemove", this.output);
this.createTerminal(newOutput);
this.terminal.connectors = oldTerminal.connectors.map((c) => {
return new Connector(this.getManager(), this.terminal, c.inputHandle);
return new Connector(this.getManager().canvasManager, this.terminal, c.inputHandle);
});
this.terminal.destroyInvalidConnections();
oldTerminal.destroy();
@@ -124,7 +138,7 @@ export default {
...parameters,
collection_type: collection_type,
collection_type_source: collection_type_source,
datatypes: output.extensions,
datatypes: this.extensions,
});
} else if (output.parameter) {
this.terminal = new terminalClass({
@@ -134,7 +148,7 @@ export default {
} else {
this.terminal = new terminalClass({
...parameters,
datatypes: output.extensions,
datatypes: this.extensions,
});
}
new OutputDragging(this.getManager(), {
@@ -1,4 +1,3 @@
import $ from "jquery";
import * as d3 from "d3";
import { Toast } from "ui/toast";
@@ -13,9 +12,17 @@ const ribbonOuterSingle = 6;
const ribbonInnerMultiple = 1;
const ribbonOuterMultiple = 3;
function offset(element) {
const rect = element.getBoundingClientRect();
return {
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
};
}
class Connector {
constructor(manager = {}, outputHandle = null, inputHandle = null) {
this.manager = manager;
constructor(canvasManager = {}, outputHandle = null, inputHandle = null) {
this.manager = canvasManager;
this.dragging = false;
this.innerClass = "ribbon-inner";
this.canvas = document.createElement("div");
@@ -64,26 +71,28 @@ class Connector {
if (!outputHandle || !inputHandle) {
return;
}
const canvas_container = $("#canvas-container");
const canvasZoom = this.manager.canvasZoom;
if (this.dragging) {
this.canvas.style.zIndex = zIndex;
}
const relativeLeft = (e) => ($(e).offset().left - canvas_container.offset().left) / canvasZoom;
const relativeTop = (e) => ($(e).offset().top - canvas_container.offset().top) / canvasZoom;
if (!outputHandle || !inputHandle) {
return;
}
// Set handle ids, used in test cases
this.canvas.setAttribute("output-handle-id", outputHandle.element.getAttribute("id"));
this.canvas.setAttribute("input-handle-id", inputHandle.element.getAttribute("id"));
// Find the position of each handle
let start_x = relativeLeft(outputHandle.element) + handleMarginX;
let start_y = relativeTop(outputHandle.element) + 0.5 * $(outputHandle.element).height();
let end_x = relativeLeft(inputHandle.element) + handleMarginX;
let end_y = relativeTop(inputHandle.element) + 0.5 * $(inputHandle.element).height();
const canvasContainer = document.getElementById("canvas-container");
const canvasZoom = this.manager.canvasZoom;
const relativeLeft = (e) => (offset(e).left - offset(canvasContainer).left) / canvasZoom + handleMarginX;
const relativeTop = (e) =>
(offset(e).top - offset(canvasContainer).top) / canvasZoom + 0.5 * inputHandle.element.offsetHeight;
let start_x = relativeLeft(outputHandle.element);
let start_y = relativeTop(outputHandle.element);
let end_x = relativeLeft(inputHandle.element);
let end_y = relativeTop(inputHandle.element);
// Calculate canvas area
const canvas_min_x = Math.min(start_x, end_x);
@@ -333,34 +333,37 @@ class BaseInputTerminal extends Terminal {
}
_producesAcceptableDatatype(other) {
// other is a non-collection output...
for (var t in this.datatypes) {
var thisDatatype = this.datatypes[t];
for (const t in this.datatypes) {
const thisDatatype = this.datatypes[t];
if (thisDatatype == "input") {
return new ConnectionAcceptable(true, null);
}
var cat_outputs = [];
const other_datatypes = other.force_datatype ? [other.force_datatype] : other.datatypes;
cat_outputs = cat_outputs.concat(other_datatypes);
// FIXME: No idea what to do about case when datatype is 'input'
for (var other_datatype_i in cat_outputs) {
var other_datatype = cat_outputs[other_datatype_i];
if (
const validMatch = other.datatypes.some(
(other_datatype) =>
other_datatype == "input" ||
other_datatype == "_sniff_" ||
this._isSubType(cat_outputs[other_datatype_i], thisDatatype)
) {
return new ConnectionAcceptable(true, null);
} else if (!this.datatypesMapper.datatypes.includes(other_datatype)) {
return new ConnectionAcceptable(
false,
`Effective output data type [${other_datatype}] unknown. This tool cannot be executed on this Galaxy Server at this moment, please contact the Administrator.`
);
}
this._isSubType(other_datatype, thisDatatype)
);
if (validMatch) {
return new ConnectionAcceptable(true, null);
}
}
const datatypesSet = new Set(this.datatypesMapper.datatypes);
const invalidDatatypes = other.datatypes.filter((datatype) => !datatypesSet.has(datatype));
if (invalidDatatypes.length) {
return new ConnectionAcceptable(
false,
`Effective output data type(s) [${invalidDatatypes.join(
", "
)}] unknown. This tool cannot be executed on this Galaxy Server at this moment, please contact the Administrator.`
);
}
return new ConnectionAcceptable(
false,
`Effective output data type(s) [${cat_outputs}] do not appear to match input type(s) [${this.datatypes}].`
`Effective output data type(s) [${other.datatypes.join(
", "
)}] do not appear to match input type(s) [${this.datatypes.join(", ")}].`
);
}
_isSubType(child, parent) {
@@ -606,10 +609,6 @@ class BaseOutputTerminal extends Terminal {
this.setMapOver(this.node.mapOver);
}
}
get force_datatype() {
const changeOutputDatatype = this.node.postJobActions["ChangeDatatypeAction" + this.name];
return changeOutputDatatype ? changeOutputDatatype.action_arguments["newtype"] : null;
}
update(output) {
this.datatypes = output.datatypes || output.extensions;
this.optional = output.optional;
@@ -576,6 +576,10 @@ workflow_editor:
destroy: '${_} .node-destroy'
clone: '${_} .node-clone'
output_data_row:
type: xpath
selector: '//div[contains(@class, "output-data-row") and contains(string(), "${output_name} (${extension})")]'
output_terminal: "${_} [output-name='${name}']"
input_terminal: "${_} [input-name='${name}']"
@@ -148,7 +148,7 @@ QUnit.test("test destroy", function (assert) {
});
QUnit.test("can accept exact datatype", function (assert) {
const other = { node: {}, datatypes: ["txt"], force_datatype: null }; // input also txt
const other = { node: {}, datatypes: ["txt"] }; // input also txt
assert.ok(this.test_accept(other));
});
@@ -162,16 +162,6 @@ QUnit.test("cannot accept incorrect datatype", function (assert) {
assert.ok(!this.test_accept(other));
});
QUnit.test("can accept incorrect datatype if converted with PJA", function (assert) {
const other = { node: {}, datatypes: ["binary"], force_datatype: "txt", name: "out1" }; // Was binary but converted to txt
assert.ok(this.test_accept(other));
});
QUnit.test("cannot accept incorrect datatype if converted with PJA to incompatible type", function (assert) {
const other = { node: {}, datatypes: ["binary"], force_datatype: "bam", name: "out1" };
assert.ok(!this.test_accept(other));
});
QUnit.test("can accept inputs", function (assert) {
// Other is data input module - always accept (currently - could be
// more intelligent by looking at what else input is connected to.
@@ -324,11 +314,11 @@ QUnit.module("Input collection terminal model test", {
QUnit.test("Collection output can connect to same collection input type", function (assert) {
const inputTerminal = this.input_terminal;
const outputTerminal = new Terminals.OutputCollectionTerminal({
datatypes: "txt",
datatypes: ["txt"],
collection_type: "list",
node: {},
});
outputTerminal.node = {postJobActions: {}};
outputTerminal.node = { postJobActions: {} };
assert.ok(
inputTerminal.canAccept(outputTerminal).canAccept,
"Input terminal " + inputTerminal + " can not accept " + outputTerminal
@@ -338,7 +328,7 @@ QUnit.test("Collection output can connect to same collection input type", functi
QUnit.test("Optional collection output can not connect to required collection input", function (assert) {
const inputTerminal = this.input_terminal;
const outputTerminal = new Terminals.OutputCollectionTerminal({
datatypes: "txt",
datatypes: ["txt"],
collection_type: "list",
optional: true,
node: {},
@@ -350,7 +340,7 @@ QUnit.test("Optional collection output can not connect to required collection in
QUnit.test("Collection output cannot connect to different collection input type", function (assert) {
const inputTerminal = this.input_terminal;
const outputTerminal = new Terminals.OutputCollectionTerminal({
datatypes: "txt",
datatypes: ["txt"],
collection_type: "paired",
node: {},
});
@@ -519,7 +509,7 @@ QUnit.test("update_field_data destroys old terminals", function (assert) {
this.update_field_data_with_new_input();
Vue.nextTick(() => {
assert.ok(destroy_spy.called);
})
});
});
});
@@ -698,37 +688,38 @@ QUnit.test("replacing terminal on data input with collection changes mapping vie
});
});
QUnit.test("replacing terminal on data collection input with simple input changes mapping view type", function (
assert
) {
const node = this.node;
node.inputs.push({ name: "TestName", extensions: ["txt"], input_type: "parameter" });
this.connectAttachedMappedOutput((connector) => {
const connector_destroy_spy = sinon.spy(connector, "destroy");
const data = {
inputs: [{ name: "TestName", extensions: ["txt"], input_type: "dataset" }],
outputs: [],
};
node.setNode(data);
Vue.nextTick(() => {
$(node.element).find(".input-terminal")[0].terminal;
assert.ok(connector_destroy_spy.called);
QUnit.test(
"replacing terminal on data collection input with simple input changes mapping view type",
function (assert) {
const node = this.node;
node.inputs.push({ name: "TestName", extensions: ["txt"], input_type: "parameter" });
this.connectAttachedMappedOutput((connector) => {
const connector_destroy_spy = sinon.spy(connector, "destroy");
const data = {
inputs: [{ name: "TestName", extensions: ["txt"], input_type: "dataset" }],
outputs: [],
};
node.setNode(data);
Vue.nextTick(() => {
$(node.element).find(".input-terminal")[0].terminal;
assert.ok(connector_destroy_spy.called);
});
});
});
});
}
);
// global InputTerminalView
QUnit.module("Input terminal view", {
beforeEach: function () {
this.node = buildNode({ type: "tool", name: "newnode" });
this.input = { name: "i1", extensions: "txt", multiple: false };
this.input = { name: "i1", extensions: ["txt"], multiple: false };
this.node.initData({ inputs: [this.input], outputs: [] });
},
});
QUnit.test("terminal added to node", function (assert) {
assert.ok(this.node.inputTerminals.i1);
assert.equal(this.node.inputTerminals.i1.datatypes, ["txt"]);
assert.equal(this.node.inputTerminals.i1.datatypes[0], "txt");
assert.equal(this.node.inputTerminals.i1.multiple, false);
});
@@ -746,14 +737,14 @@ QUnit.test("terminal element", function (assert) {
QUnit.module("Output terminal view", {
beforeEach: function () {
this.node = buildNode({ type: "tool", name: "newnode" });
this.output = { name: "o1", extensions: "txt" };
this.output = { name: "o1", extensions: ["txt"] };
this.node.initData({ inputs: [], outputs: [this.output] });
},
});
QUnit.test("terminal added to node", function (assert) {
assert.ok(this.node.outputTerminals.o1);
assert.equal(this.node.outputTerminals.o1.datatypes, ["txt"]);
assert.equal(this.node.outputTerminals.o1.datatypes[0], "txt");
});
QUnit.test("terminal element", function (assert) {
@@ -1022,7 +1013,7 @@ QUnit.module("terminal mapping logic", {
verifyNotMappedOver: function (assert, terminal) {
assert.ok(!terminal.mapOver.isCollection);
},
verifyDefaultMapOver: function(assert, terminal) {
verifyDefaultMapOver: function (assert, terminal) {
const outputCollectionTerminal = this.newOutputCollectionTerminal("list");
assert.ok(!terminal.node.mapOver);
const connector = new Connector({}, outputCollectionTerminal, terminal);
@@ -1030,7 +1021,7 @@ QUnit.module("terminal mapping logic", {
assert.ok(terminal.node.mapOver);
terminal.disconnect(connector);
assert.ok(!terminal.node.mapOver);
}
},
});
QUnit.test("unconstrained input can be mapped over", function (assert) {
@@ -1046,14 +1037,15 @@ QUnit.test("unmapped input can be mapped over if matching connected input termin
this.verifyAttachable(assert, this.inputTerminal1, "list");
});
QUnit.test("unmapped input cannot be mapped over if not matching connected input terminals map type", function (
assert
) {
this.inputTerminal1 = this.newInputTerminal();
const connectedInput = this.addConnectedInput(this.inputTerminal1);
connectedInput.setMapOver(new Terminals.CollectionTypeDescription("paired"));
this.verifyNotAttachable(assert, this.inputTerminal1, "list");
});
QUnit.test(
"unmapped input cannot be mapped over if not matching connected input terminals map type",
function (assert) {
this.inputTerminal1 = this.newInputTerminal();
const connectedInput = this.addConnectedInput(this.inputTerminal1);
connectedInput.setMapOver(new Terminals.CollectionTypeDescription("paired"));
this.verifyNotAttachable(assert, this.inputTerminal1, "list");
}
);
QUnit.test(
"unmapped input can be attached to by output collection if matching connected input terminals map type",
@@ -1137,17 +1129,18 @@ QUnit.test("unmapped input with connected mapped outputs can be mapped over if m
this.verifyAttachable(assert, this.inputTerminal1, "list");
});
QUnit.test("unmapped input with connected mapped outputs cannot be mapped over if mapover not matching", function (
assert
) {
// It would invalidate the connections - someday maybe we could try to
// recursively map over everything down the DAG - it would be expensive
// to check that though.
this.inputTerminal1 = this.newInputTerminal();
const connectedOutput = this.addConnectedOutput(this.inputTerminal1);
connectedOutput.setMapOver(new Terminals.CollectionTypeDescription("paired"));
this.verifyNotAttachable(assert, this.inputTerminal1, "list");
});
QUnit.test(
"unmapped input with connected mapped outputs cannot be mapped over if mapover not matching",
function (assert) {
// It would invalidate the connections - someday maybe we could try to
// recursively map over everything down the DAG - it would be expensive
// to check that though.
this.inputTerminal1 = this.newInputTerminal();
const connectedOutput = this.addConnectedOutput(this.inputTerminal1);
connectedOutput.setMapOver(new Terminals.CollectionTypeDescription("paired"));
this.verifyNotAttachable(assert, this.inputTerminal1, "list");
}
);
QUnit.test("explicitly constrained input can not be mapped over by incompatible collection type", function (assert) {
this.inputTerminal1 = this.newInputTerminal();
@@ -1245,15 +1238,16 @@ QUnit.test("resetMappingIfNeeded an input resets node outputs if they not connec
this.verifyNotMappedOver(assert, output);
});
QUnit.test("resetMappingIfNeeded an input resets node collection outputs if they not connected to anything", function (
assert
) {
this.inputTerminal1 = this.newInputTerminal("list");
const output = this.addCollectionOutput(this.inputTerminal1);
output.setMapOver(new Terminals.CollectionTypeDescription("list"));
this.inputTerminal1.resetMappingIfNeeded();
this.verifyNotMappedOver(assert, output);
});
QUnit.test(
"resetMappingIfNeeded an input resets node collection outputs if they not connected to anything",
function (assert) {
this.inputTerminal1 = this.newInputTerminal("list");
const output = this.addCollectionOutput(this.inputTerminal1);
output.setMapOver(new Terminals.CollectionTypeDescription("list"));
this.inputTerminal1.resetMappingIfNeeded();
this.verifyNotMappedOver(assert, output);
}
);
QUnit.test("resetMappingIfNeeded resets if not last mapped over input", function (assert) {
// Idea here is that other nodes are forcing output to still be mapped
+1 -1
View File
@@ -1092,7 +1092,7 @@ class WorkflowContentsManager(UsesAnnotations):
"outputs": module.get_all_outputs(),
"config_form": config_form,
"annotation": annotation_str,
"post_job_actions": {},
"post_job_actions": module.get_post_job_actions({}),
"uuid": str(step.uuid) if step.uuid else None,
"workflow_outputs": [],
}
+19 -2
View File
@@ -227,7 +227,7 @@ class WorkflowModule:
return self.get_all_outputs(data_only=True)
def get_post_job_actions(self, incoming):
return []
return {}
def check_and_update_state(self):
"""
@@ -428,6 +428,10 @@ class SubWorkflowModule(WorkflowModule):
_modules: Optional[List[Any]] = None
subworkflow: Workflow
def __init__(self, trans, content_id=None, **kwds):
super().__init__(trans, content_id, **kwds)
self.post_job_actions: Optional[Dict[str, Any]] = None
@classmethod
def from_dict(Class, trans, d, **kwds):
module = super().from_dict(trans, d, **kwds)
@@ -510,6 +514,7 @@ class SubWorkflowModule(WorkflowModule):
def get_all_outputs(self, data_only=False):
outputs = []
self.post_job_actions = {}
if hasattr(self.subworkflow, "workflow_outputs"):
from galaxy.managers.workflows import WorkflowContentsManager
@@ -535,7 +540,7 @@ class SubWorkflowModule(WorkflowModule):
data_output["name"] == workflow_output["output_name"]
or data_output_uuid == workflow_output_uuid
):
data_output["name"] = label
data_output["label"] = label
# That's the right data_output
break
else:
@@ -546,9 +551,21 @@ class SubWorkflowModule(WorkflowModule):
f"Workflow output '{workflow_output['output_name']}' defined, but not listed among data outputs"
)
continue
post_job_actions = step["post_job_actions"].copy()
change_datatype_action = post_job_actions.pop(f"ChangeDatatypeAction{data_output['name']}", None)
# Post job actions are referred to by tool output name,
# but that's not guaranteed to be unique within a workflow,
# but the label is unique.
if change_datatype_action:
post_job_actions[f"ChangeDatatypeAction{label}"] = change_datatype_action
self.post_job_actions.update(post_job_actions)
outputs.append(data_output)
return outputs
def get_post_job_actions(self, incoming):
return self.post_job_actions
def get_content_id(self):
return self.trans.security.encode_id(self.subworkflow.id)
+1 -1
View File
@@ -517,7 +517,7 @@ class WorkflowRefactorExecutor:
# TODO: find workflow outputs that need to be dropped and report them
upgrade_inputs = step.module.get_all_inputs()
upgrade_outputs = step.module.get_all_outputs()
upgrade_output_names = [u["name"] for u in upgrade_outputs]
upgrade_output_names = [u.get("label") or u["name"] for u in upgrade_outputs]
upgrade_order_index = step_def["id"]
upgrade_label = step_def.get("label")
all_input_connections = step_def.get("input_connections")
@@ -1,6 +1,7 @@
import json
import yaml
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from galaxy_test.base.workflow_fixtures import (
@@ -531,6 +532,64 @@ steps:
element.wait_for_and_send_keys(Keys.BACKSPACE)
element.wait_for_and_send_keys(value)
@selenium_test
def test_change_datatype(self):
self.open_in_workflow_editor(
"""
class: GalaxyWorkflow
inputs: []
steps:
- tool_id: create_2
label: create_2
- tool_id: checksum
label: checksum
in:
input: create_2/out_file1
"""
)
editor = self.components.workflow_editor
self.assert_connected("create_2#out_file1", "checksum#input")
node = editor.node._(label="create_2")
node.wait_for_and_click()
editor.configure_output(output="out_file1").wait_for_and_click()
editor.change_datatype.wait_for_and_click()
editor.select_dataype_text_search.wait_for_and_send_keys("bam")
editor.select_datatype(datatype="bam").wait_for_and_click()
editor.node.output_data_row(output_name="out_file1", extension="bam").wait_for_visible()
self.assert_not_connected("create_2#out_file1", "checksum#input")
def test_change_datatype_in_subworkflow(self):
self.open_in_workflow_editor(
"""
class: GalaxyWorkflow
inputs: []
steps:
nested_workflow:
run:
class: GalaxyWorkflow
inputs: []
steps:
- tool_id: create_2
label: create_2
outputs:
out_file1:
change_datatype: bam
outputs:
workflow_output:
outputSource: create_2/out_file1
metadata_bam:
tool_id: metadata_bam
"""
)
editor = self.components.workflow_editor
node = editor.node._(label="nested_workflow")
node.wait_for_and_click()
node.output_data_row(output_name="workflow_output", extension="bam").wait_for_visible()
# Move canvas, so terminals are in viewport
self.move_center_of_canvas(xoffset=100, yoffset=100)
self.workflow_editor_connect("nested_workflow#out_file1", "metadata_bam#input_bam")
self.assert_connected("nested_workflow#out_file1", "metadata_bam#input_bam")
@selenium_test
def test_editor_duplicate_node(self):
workflow_id = self.workflow_populator.upload_yaml_workflow(WORKFLOW_SIMPLE_CAT_TWICE)
@@ -788,3 +847,8 @@ steps:
modal_element = self.components.workflow_editor.state_modal_body.wait_for_visible()
text = modal_element.text
assert expected_text in text, f"Failed to find expected text [{expected_text}] in modal text [{text}]"
def move_center_of_canvas(self, xoffset=0, yoffset=0):
canvas = self.driver.find_element_by_id("canvas-container")
chains = ActionChains(self.driver)
chains.click_and_hold(canvas).move_by_offset(xoffset=xoffset, yoffset=yoffset).release().perform()
+2 -2
View File
@@ -244,9 +244,9 @@ def test_subworkflow_new_outputs():
outputs = subworkflow_module.get_data_outputs()
assert len(outputs) == 2, len(outputs)
output1, output2 = outputs
assert output1["name"] == "out1"
assert output1["label"] == "out1"
assert output1["extensions"] == ["input"]
assert output2["name"] == "4:out_file1", output2["name"]
assert output2["label"] == "4:out_file1", output2["label"]
class MapOverTestCase(NamedTuple):