diff --git a/test/functional/tools/parameters/gx_data_collection_list_or_list_paired.yml b/test/functional/tools/parameters/gx_data_collection_list_or_list_paired.yml new file mode 100644 index 00000000000..b60477e602c --- /dev/null +++ b/test/functional/tools/parameters/gx_data_collection_list_or_list_paired.yml @@ -0,0 +1,18 @@ +class: GalaxyTool +id: gx_data_collection_list_or_list_paired +name: gx_data_collection_list_or_list_paired +version: "1.0.0" +profile: "25.0" +container: busybox +shell_command: | + echo "Collection type: $(inputs.parameter.collection_type)" > output.txt +inputs: + - name: parameter + type: data_collection + collection_type: "list,list:paired" + label: Input list or list:paired collection +outputs: + - name: output + type: data + format: txt + from_work_dir: output.txt diff --git a/test/functional/tools/parameters/gx_data_collection_list_paired_or_list_list.yml b/test/functional/tools/parameters/gx_data_collection_list_paired_or_list_list.yml new file mode 100644 index 00000000000..98751f70b59 --- /dev/null +++ b/test/functional/tools/parameters/gx_data_collection_list_paired_or_list_list.yml @@ -0,0 +1,18 @@ +class: GalaxyTool +id: gx_data_collection_list_paired_or_list_list +name: gx_data_collection_list_paired_or_list_list +version: "1.0.0" +profile: "25.0" +container: busybox +shell_command: | + echo "Collection type: $(inputs.parameter.collection_type)" > output.txt +inputs: + - name: parameter + type: data_collection + collection_type: "list:paired,list:list" + label: Input list:paired or list:list collection +outputs: + - name: output + type: data + format: txt + from_work_dir: output.txt diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml index 7e9d6ee5507..815beade1c1 100644 --- a/test/functional/tools/sample_tool_conf.xml +++ b/test/functional/tools/sample_tool_conf.xml @@ -304,6 +304,8 @@ + +
diff --git a/test/unit/tool_util/parameter_specification.yml b/test/unit/tool_util/parameter_specification.yml index 23ae1f683ae..1837b543433 100644 --- a/test/unit/tool_util/parameter_specification.yml +++ b/test/unit/tool_util/parameter_specification.yml @@ -3709,6 +3709,240 @@ gx_data_collection_paired_or_pou: format: "txt" size: 100 +gx_data_collection_list_paired_or_list_list: + request_valid: + - parameter: {src: hdca, id: abcdabcd} + request_invalid: + - parameter: {src: hdca, id: 7} + - parameter: null + - {} + request_internal_valid: + - parameter: {src: hdca, id: 5} + request_internal_invalid: + - parameter: {src: hdca, id: abcdabcd} + - parameter: null + - {} + job_internal_valid: + - parameter: {src: hdca, id: 5} + job_internal_invalid: + - parameter: {src: hdca, id: abcdabcd} + - {} + job_runtime_valid: + # list:paired is accepted + - parameter: + class: Collection + name: test_lp + collection_type: "list:paired" + tags: [] + elements: + - class: Collection + name: p1 + collection_type: paired + tags: [] + elements: + forward: + class: File + element_identifier: forward + basename: "f.txt" + location: "step_input://0" + path: "/tmp/f.txt" + nameroot: "f" + nameext: ".txt" + format: "txt" + size: 100 + reverse: + class: File + element_identifier: reverse + basename: "r.txt" + location: "step_input://1" + path: "/tmp/r.txt" + nameroot: "r" + nameext: ".txt" + format: "txt" + size: 100 + # list:list is also accepted + - parameter: + class: Collection + name: test_ll + collection_type: "list:list" + tags: [] + elements: + - class: Collection + name: inner1 + collection_type: list + tags: [] + elements: + - class: File + element_identifier: f1 + basename: "f1.txt" + location: "step_input://0" + path: "/tmp/f1.txt" + nameroot: "f1" + nameext: ".txt" + format: "txt" + size: 100 + job_runtime_invalid: + - parameter: {src: hdca, id: 5} + - {} + # Simple paired -- not in union (union is list:paired, list:list) + - parameter: + class: Collection + name: wrong_type + collection_type: paired + tags: [] + elements: + forward: + class: File + element_identifier: forward + basename: "f.txt" + location: "step_input://0" + path: "/tmp/f.txt" + nameroot: "f" + nameext: ".txt" + format: "txt" + size: 100 + reverse: + class: File + element_identifier: reverse + basename: "r.txt" + location: "step_input://1" + path: "/tmp/r.txt" + nameroot: "r" + nameext: ".txt" + format: "txt" + size: 100 + # Simple list -- not in union (union is list:paired, list:list, not plain list) + - parameter: + class: Collection + name: wrong_type_simple_list + collection_type: list + tags: [] + elements: + - class: File + element_identifier: f1 + basename: "f1.txt" + location: "step_input://0" + path: "/tmp/f1.txt" + nameroot: "f1" + nameext: ".txt" + format: "txt" + size: 100 + +gx_data_collection_list_or_list_paired: + request_valid: + - parameter: {src: hdca, id: abcdabcd} + request_invalid: + - parameter: {src: hdca, id: 7} + - parameter: null + - {} + request_internal_valid: + - parameter: {src: hdca, id: 5} + request_internal_invalid: + - parameter: {src: hdca, id: abcdabcd} + - parameter: null + - {} + job_internal_valid: + - parameter: {src: hdca, id: 5} + job_internal_invalid: + - parameter: {src: hdca, id: abcdabcd} + - {} + job_runtime_valid: + # Simple list is accepted + - parameter: + class: Collection + name: test_list + collection_type: list + tags: [] + elements: + - class: File + element_identifier: f1 + basename: "f1.txt" + location: "step_input://0" + path: "/tmp/f1.txt" + nameroot: "f1" + nameext: ".txt" + format: "txt" + size: 100 + # list:paired is also accepted + - parameter: + class: Collection + name: test_lp + collection_type: "list:paired" + tags: [] + elements: + - class: Collection + name: p1 + collection_type: paired + tags: [] + elements: + forward: + class: File + element_identifier: forward + basename: "f.txt" + location: "step_input://0" + path: "/tmp/f.txt" + nameroot: "f" + nameext: ".txt" + format: "txt" + size: 100 + reverse: + class: File + element_identifier: reverse + basename: "r.txt" + location: "step_input://1" + path: "/tmp/r.txt" + nameroot: "r" + nameext: ".txt" + format: "txt" + size: 100 + job_runtime_invalid: + - parameter: {src: hdca, id: 5} + - {} + # Paired -- not in union (union is list, list:paired) + - parameter: + class: Collection + name: wrong_type + collection_type: paired + tags: [] + elements: + forward: + class: File + element_identifier: forward + basename: "f.txt" + location: "step_input://0" + path: "/tmp/f.txt" + nameroot: "f" + nameext: ".txt" + format: "txt" + size: 100 + reverse: + class: File + element_identifier: reverse + basename: "r.txt" + location: "step_input://1" + path: "/tmp/r.txt" + nameroot: "r" + nameext: ".txt" + format: "txt" + size: 100 + # Record -- not in union + - parameter: + class: Collection + name: wrong_type_record + collection_type: record + tags: [] + elements: + field1: + class: File + element_identifier: field1 + basename: "f1.txt" + location: "step_input://0" + path: "/tmp/f1.txt" + nameroot: "f1" + nameext: ".txt" + format: "txt" + size: 100 + cwl_int: request_valid: - parameter: 5 diff --git a/test/unit/tool_util/test_runtime.py b/test/unit/tool_util/test_runtime.py index 27745f98489..9326121c067 100644 --- a/test/unit/tool_util/test_runtime.py +++ b/test/unit/tool_util/test_runtime.py @@ -123,14 +123,13 @@ def test_collection_to_runtime_nested(mock_adapt_dataset): compute_environment=None ) - assert isinstance(runtime, DataCollectionInternalJsonBase) + expected_model = build_collection_model_for_type("list:paired") + assert isinstance(runtime, expected_model) + assert type(runtime).__name__ == "DynamicCollection_list_paired" assert runtime.collection_type == "list:paired" assert len(runtime.elements) == 1 - + inner = runtime.elements[0] - - # It should be a paired runtime because that's what we put in - # With the updated nested model, Pydantic should match the inner dict to DataCollectionPairedRuntime assert isinstance(inner, DataCollectionPairedRuntime) assert inner.elements.forward.class_ == "File" @@ -320,3 +319,116 @@ def test_dynamic_model_caching(): m1 = build_collection_model_for_type("list:paired") m2 = build_collection_model_for_type("list:paired") assert m1 is m2 + + +# --- Step 2: E2E list:list:paired (gap #1) --- + +def test_collection_to_runtime_deeply_nested(mock_adapt_dataset): + """End-to-end: list:list:paired through collection_to_runtime pipeline.""" + paired_data = ["paired", [("forward", "hda1"), ("reverse", "hda2")]] + list_paired_data = ["list:paired", [("p1", paired_data)]] + collection = create_model_collection("list:list:paired", [("batch1", list_paired_data)]) + + runtime = collection_to_runtime( + collection, + name="test_deeply_nested", + tags=[], + adapt_dataset=mock_adapt_dataset, + compute_environment=None, + ) + + expected_model = build_collection_model_for_type("list:list:paired") + assert isinstance(runtime, expected_model) + assert runtime.collection_type == "list:list:paired" + assert len(runtime.elements) == 1 + + middle = runtime.elements[0] + expected_middle = build_collection_model_for_type("list:paired") + assert isinstance(middle, expected_middle) + assert middle.collection_type == "list:paired" + assert len(middle.elements) == 1 + + inner = middle.elements[0] + assert isinstance(inner, DataCollectionPairedRuntime) + assert inner.elements.forward.class_ == "File" + + +# --- Step 3: record:* nested dynamic models (gap #2) --- + +def test_dynamic_model_record_paired_accepts_correct(): + """record:paired dynamic model: dict of paired collections.""" + model = build_collection_model_for_type("record:paired") + assert model is not None + result = model.model_validate({ + "class": "Collection", "name": "rec", + "collection_type": "record:paired", "tags": [], + "elements": { + "sample_a": { + "class": "Collection", "name": "sample_a", + "collection_type": "paired", "tags": [], + "elements": { + "forward": {**_FILE_ELEMENT, "element_identifier": "forward"}, + "reverse": {**_FILE_ELEMENT, "element_identifier": "reverse", + "basename": "r.txt", "location": "step_input://1", + "path": "/tmp/r.txt", "nameroot": "r"}, + }, + }, + }, + }) + assert result.collection_type == "record:paired" + assert isinstance(result.elements["sample_a"], DataCollectionPairedRuntime) + + +def test_dynamic_model_record_paired_rejects_wrong_inner(): + """record:paired rejects inner collection with wrong type (list instead of paired).""" + model = build_collection_model_for_type("record:paired") + with pytest.raises(ValidationError): + model.model_validate({ + "class": "Collection", "name": "bad_rec", + "collection_type": "record:paired", "tags": [], + "elements": { + "sample_a": { + "class": "Collection", "name": "sample_a", + "collection_type": "list", "tags": [], + "elements": [_FILE_ELEMENT], + }, + }, + }) + + +# --- Step 4: Unknown nested segments return None (gap #5) --- + +def test_build_collection_model_returns_none_for_unknown_nested(): + assert build_collection_model_for_type("list:unknown_type") is None + assert build_collection_model_for_type("record:unknown") is None + assert build_collection_model_for_type("list:list:unknown") is None + + +# --- Step 5: Empty dict elements on record-like dynamic model (gap #6) --- + +def test_dynamic_model_record_paired_empty_elements(): + """Empty dict elements on a record-like dynamic model should validate.""" + model = build_collection_model_for_type("record:paired") + result = model.model_validate({ + "class": "Collection", "name": "empty_rec", + "collection_type": "record:paired", "tags": [], + "elements": {}, + }) + assert result.elements == {} + + +# --- Step 6: JSON schema for deeply nested models (gap #7) --- + +def test_dynamic_model_json_schema_deeply_nested(): + """JSON Schema for list:list:paired has 3 levels, no anyOf at any level.""" + model = build_collection_model_for_type("list:list:paired") + schema = model.model_json_schema() + # Outer elements: array of list:paired models + elements_schema = schema["properties"]["elements"] + schema_str = str(elements_schema) + assert "anyOf" not in schema_str + + # Should have definitions for multiple distinct models (at least the inner dynamic + leaf) + defs = schema.get("$defs", {}) + assert len(defs) >= 2 + assert any("list_paired" in k for k in defs.keys())