Commit 19172d9a authored by Flavio TARSETTI's avatar Flavio TARSETTI
Browse files

Merge branch '180_algorithm_editor' into 'v2'

Algorithm editor

See merge request !100
parents 20912450 bc311834
Pipeline #31689 passed with stage
in 24 minutes and 49 seconds
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2019 Idiap Research Institute, http://www.idiap.ch/ #
# Contact: beat.support@idiap.ch #
# #
# This file is part of the beat.editor module of the BEAT platform. #
# #
# Commercial License Usage #
# Licensees holding valid commercial BEAT licenses may use this file in #
# accordance with the terms contained in a written agreement between you #
# and Idiap. For further information contact tto@idiap.ch #
# #
# Alternatively, this file may be used under the terms of the GNU Affero #
# Public License version 3 as published by the Free Software and appearing #
# in the file LICENSE.AGPL included in the packaging of this file. #
# The BEAT platform 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. #
# #
# You should have received a copy of the GNU Affero Public License along #
# with the BEAT platform. If not, see http://www.gnu.org/licenses/. #
# #
###############################################################################
from PyQt5.QtWidgets import QComboBox
from PyQt5.QtWidgets import QStyledItemDelegate
from .assetmodel import AssetModel
class AssetItemDelegate(QStyledItemDelegate):
"""Delegate to edit asset entries"""
def __init__(self, asset_model, parent=None):
super(AssetItemDelegate, self).__init__(parent)
if not isinstance(asset_model, AssetModel):
raise TypeError("Wrong model type")
self.asset_model = asset_model
def createEditor(self, parent, options, index):
"""Create a combox box with all available assets"""
combobox = QComboBox(parent)
combobox.setModel(self.asset_model)
return combobox
......@@ -23,16 +23,393 @@
# #
###############################################################################
import pytest
from PyQt5 import QtCore
from ..backend.asset import Asset
from ..backend.asset import AssetType
from ..backend.assetmodel import AssetModel
from ..widgets.algorithmeditor import PropertyEditor
from ..widgets.algorithmeditor import ParameterEditor
from ..widgets.algorithmeditor import ResultEditor
from ..widgets.algorithmeditor import IOWidget
from ..widgets.algorithmeditor import GroupEditor
from ..widgets.algorithmeditor import AlgorithmEditor
from ..widgets.algorithmeditor import ALGORITHM_TYPE
from ..widgets.algorithmeditor import DEFAULT_SCHEMA_VERSION
from ..widgets.algorithmeditor import DEFAULT_API_VERSION
from ..widgets.algorithmeditor import migrate_to_api_v2
from ..widgets.algorithmeditor import update_code
from .conftest import sync_prefix
from .conftest import prefix
def get_algorithm_declaration(prefix_path, algorithm_name):
asset = Asset(prefix_path, AssetType.ALGORITHM, algorithm_name)
return asset.declaration
def get_valid_algorithm(test_prefix):
sync_prefix()
model = AssetModel()
model.asset_type = AssetType.ALGORITHM
model.prefix_path = test_prefix
model.setLatestOnlyEnabled(False)
return [
algorithm
for algorithm in model.stringList()
if all(
invalid not in algorithm
for invalid in ["errors", "legacy", "invalid", "v1"]
)
]
@pytest.fixture()
def dataformat_model(test_prefix):
model = AssetModel()
model.asset_type = AssetType.DATAFORMAT
model.prefix_path = test_prefix
return model
@pytest.fixture(autouse=True)
def synced_prefix():
"""Re sync the prefix between test"""
sync_prefix()
class TestHelperMethods:
def test_migration(self, test_prefix):
algorithm = "v1/sum/1"
asset = Asset(test_prefix, AssetType.ALGORITHM, algorithm)
result, new_asset = migrate_to_api_v2(asset)
assert result
assert new_asset.name != asset.name
v1_declaration = asset.declaration
v2_declaration = new_asset.declaration
assert v1_declaration != v2_declaration
assert v2_declaration["schema_version"] == 2
assert v2_declaration["api_version"] == 2
assert v2_declaration["type"] == "legacy"
@pytest.mark.parametrize(
["algorithm_name", "new_type", "field_to_add"],
[
("autonomous/add/1", "legacy", None),
("autonomous/add/1", "sequential", None),
("sequential/add/1", "autonomous", None),
("sequential/add/1", "loop_user", None),
("sequential/add/1", "loop", None),
("autonomous/loop/1", "sequential", None),
("autonomous/loop/1", "sequential", "results"),
("autonomous/add/1", "sequential", "results"),
],
)
def test_code_update(self, test_prefix, algorithm_name, new_type, field_to_add):
asset = Asset(test_prefix, AssetType.ALGORITHM, algorithm_name)
with open(asset.code_path, "rt") as code_file:
original_code = code_file.read()
declaration = asset.declaration
declaration["type"] = new_type
if field_to_add:
declaration[field_to_add] = None
asset.declaration = declaration
update_code(asset)
with open(asset.code_path, "rt") as code_file:
updated_code = code_file.read()
assert updated_code != original_code
class TestPropertyEditor:
"""Test that the algorithm properties editor works as expected"""
@pytest.mark.parametrize("algorithm", get_valid_algorithm(prefix))
def test_load_and_dump(self, qtbot, test_prefix, algorithm):
reference_json = get_algorithm_declaration(test_prefix, algorithm)
editor = PropertyEditor()
qtbot.addWidget(editor)
editor.load(reference_json)
reference_json.pop("groups", None)
reference_json.pop("parameters", None)
reference_json.pop("results", None)
reference_json.pop("uses", None)
assert editor.dump() == reference_json
def test_default_dump(self, qtbot):
editor = PropertyEditor()
qtbot.addWidget(editor)
assert editor.dump() == {
"language": "unknown",
"api_version": DEFAULT_API_VERSION,
"type": "sequential",
"schema_version": DEFAULT_SCHEMA_VERSION,
"splittable": False,
}
def test_splittable(self, qtbot):
editor = PropertyEditor()
qtbot.addWidget(editor)
assert editor.canBeSplitable()
assert editor.splittable_checkbox.isEnabled()
for button in [
editor.autonomous_radiobutton,
editor.loopuser_radiobutton,
editor.sequential_radiobutton,
]:
with qtbot.waitSignal(editor.dataChanged):
button.toggle()
assert editor.canBeSplitable()
with qtbot.waitSignal(editor.dataChanged):
editor.loop_radiobutton.toggle()
assert not editor.canBeSplitable()
assert not editor.splittable_checkbox.isEnabled()
with qtbot.waitSignal(editor.dataChanged):
editor.analyzer_checkbox.toggle()
assert not editor.canBeSplitable()
def test_has_outputs(self, qtbot):
editor = PropertyEditor()
qtbot.addWidget(editor)
assert editor.hasOutputs()
with qtbot.waitSignal(editor.dataChanged):
editor.analyzer_checkbox.toggle()
assert not editor.hasOutputs()
with qtbot.waitSignal(editor.dataChanged):
editor.analyzer_checkbox.toggle()
assert editor.hasOutputs()
with qtbot.waitSignal(editor.dataChanged):
editor.loop_radiobutton.toggle()
assert not editor.hasOutputs()
@pytest.mark.parametrize(
["algorithm_type", "has_loop"],
[
("sequential", False),
("autonomous", False),
("loop", True),
("loop_user", True),
],
)
def test_has_loop(self, qtbot, algorithm_type, has_loop):
editor = PropertyEditor()
qtbot.addWidget(editor)
assert not editor.hasLoop()
for button in editor.button_group.buttons():
if button.property(ALGORITHM_TYPE) == algorithm_type:
button.setChecked(True)
break
assert editor.hasLoop() == has_loop
@pytest.mark.parametrize(
["algorithm_type", "schema_version"],
[
("sequential", DEFAULT_SCHEMA_VERSION),
("autonomous", DEFAULT_SCHEMA_VERSION),
("loop", 3),
("loop_user", 3),
],
)
def test_schema_version(self, qtbot, algorithm_type, schema_version):
editor = PropertyEditor()
qtbot.addWidget(editor)
for button in editor.button_group.buttons():
if button.property(ALGORITHM_TYPE) == algorithm_type:
button.setChecked(True)
break
assert editor.dump().get("schema_version") == schema_version
class TestParameterEditor:
"""Test that the algorithm parameter editor works as expected"""
@pytest.mark.parametrize("algorithm", get_valid_algorithm(prefix))
def test_load_and_dump(self, qtbot, test_prefix, algorithm):
reference_json = get_algorithm_declaration(test_prefix, algorithm)
parameters = reference_json.pop("parameters", {})
editor = ParameterEditor()
qtbot.addWidget(editor)
for _, parameter in parameters.items():
editor.load(parameter)
assert editor.dump() == parameter
def test_name(self, qtbot):
editor = ParameterEditor()
qtbot.addWidget(editor)
new_name = "test"
with qtbot.waitSignal(editor.dataChanged):
editor.setName(new_name)
assert editor.name() == new_name
class TestResultEditor:
"""Test that the algorithm result editor works as expected"""
@pytest.fixture()
def editor(self, qtbot, dataformat_model):
editor = ResultEditor(dataformat_model)
qtbot.addWidget(editor)
return editor
@pytest.mark.parametrize("algorithm", get_valid_algorithm(prefix))
def test_load_and_dump(self, qtbot, test_prefix, editor, algorithm):
reference_json = get_algorithm_declaration(test_prefix, algorithm)
results = reference_json.pop("results", {})
for _, result in results.items():
editor.load(result)
assert editor.dump() == result
def test_name(self, qtbot, editor):
new_name = "test"
with qtbot.waitSignal(editor.dataChanged):
editor.setName(new_name)
assert editor.name() == new_name
def test_type_change(self, qtbot, editor):
model = editor.type_combobox.model()
dataformat = model.index(1, 0).data()
with qtbot.waitSignal(editor.dataChanged):
editor.type_combobox.setCurrentIndex(1)
assert editor.dump()["type"] == dataformat
def test_display_change(self, qtbot, editor):
assert not editor.dump()["display"]
with qtbot.waitSignal(editor.dataChanged):
editor.display_checkbox.toggle()
assert editor.dump()["display"]
with qtbot.waitSignal(editor.dataChanged):
editor.display_checkbox.toggle()
assert not editor.dump()["display"]
class TestIOWidget(object):
"""Tests for the widget for input output handling"""
@pytest.fixture()
def editor(self, qtbot, dataformat_model):
editor = IOWidget("Test", dataformat_model)
qtbot.addWidget(editor)
return editor
@pytest.mark.parametrize("algorithm", get_valid_algorithm(prefix))
def test_load_and_dump(self, qtbot, test_prefix, dataformat_model, algorithm):
reference_json = get_algorithm_declaration(test_prefix, algorithm)
groups = reference_json.pop("groups", {})
for group in groups:
inputs = group.get("inputs", {})
outputs = group.get("outputs", {})
editor = IOWidget("Test", dataformat_model)
qtbot.addWidget(editor)
editor.load(inputs)
assert editor.dump() == inputs
editor.load(outputs)
assert editor.dump() == outputs
def test_add_entry(self, qtbot, test_prefix, editor):
assert len(editor.dump()) == 0
with qtbot.waitSignal(editor.dataChanged):
qtbot.mouseClick(editor.add_button, QtCore.Qt.LeftButton)
assert len(editor.dump()) == 1
def test_remove_entry(self, qtbot, test_prefix, editor, dataformat_model):
assert not editor.remove_button.isEnabled()
editor.load({"Test": {"type": dataformat_model.stringList()[0]}})
assert not editor.remove_button.isEnabled()
with qtbot.waitSignal(editor.tablewidget.itemSelectionChanged):
editor.tablewidget.selectRow(0)
assert editor.remove_button.isEnabled()
qtbot.mouseClick(editor.remove_button, QtCore.Qt.LeftButton)
assert len(editor.dump()) == 0
assert not editor.remove_button.isEnabled()
class TestGroupEditor:
"""Test that the algorithm group editor works as expected"""
@pytest.mark.parametrize("algorithm", get_valid_algorithm(prefix))
def test_load_and_dump(self, qtbot, test_prefix, algorithm):
reference_json = get_algorithm_declaration(test_prefix, algorithm)
model = AssetModel()
model.asset_type = AssetType.DATAFORMAT
model.prefix_path = test_prefix
groups = reference_json.pop("groups", {})
editor = GroupEditor(model)
qtbot.addWidget(editor)
is_loop = reference_json.get("type") in ["loop", "loop_user"]
has_no_output = (
reference_json.get("type") == "loop" or "results" in reference_json
)
for index, group in enumerate(groups):
editor.load(group)
if not has_no_output:
editor.setOutputsEnabled(index == 0)
else:
editor.setOutputsEnabled(False)
editor.setLoopEnabled(is_loop)
assert editor.dump() == group
class TestAlgorithmEditor:
"""Test that the mock editor works correctly"""
def test_load_and_dump(self, qtbot):
reference_json = {"description": "test"}
@pytest.mark.parametrize("algorithm", get_valid_algorithm(prefix))
def test_load_and_dump(self, qtbot, beat_context, test_prefix, algorithm):
reference_json = get_algorithm_declaration(test_prefix, algorithm)
editor = AlgorithmEditor()
qtbot.addWidget(editor)
editor.set_context(beat_context)
editor.load_json(reference_json)
assert editor.dump_json() == reference_json
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2019 Idiap Research Institute, http://www.idiap.ch/ #
# Contact: beat.support@idiap.ch #
# #
# This file is part of the beat.editor module of the BEAT platform. #
# #
# Commercial License Usage #
# Licensees holding valid commercial BEAT licenses may use this file in #
# accordance with the terms contained in a written agreement between you #
# and Idiap. For further information contact tto@idiap.ch #
# #
# Alternatively, this file may be used under the terms of the GNU Affero #
# Public License version 3 as published by the Free Software and appearing #
# in the file LICENSE.AGPL included in the packaging of this file. #
# The BEAT platform 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. #
# #
# You should have received a copy of the GNU Affero Public License along #
# with the BEAT platform. If not, see http://www.gnu.org/licenses/. #
# #
###############################################################################
from PyQt5.QtCore import Qt
from ..backend.asset import AssetType
from ..backend.asset import Asset
from ..widgets.assetbrowser import AssetBrowser
class TestAssetBrowser:
def test_set_current_asset(self, qtbot, test_prefix, beat_context):
browser = AssetBrowser()
qtbot.addWidget(browser)
browser.set_context(beat_context)
asset = Asset(test_prefix, AssetType.ALGORITHM, "user/integers_add_v2/1")
with qtbot.waitSignal(browser.view.selectionModel().currentChanged):
browser.setCurrentAsset(asset)
assert browser.currentAsset() == asset
def test_double_click(self, qtbot, test_prefix, beat_context):
browser = AssetBrowser()
qtbot.addWidget(browser)
browser.set_context(beat_context)
asset = Asset(test_prefix, AssetType.ALGORITHM, "autonomous/add/1")
browser.setCurrentAsset(asset)
index = browser.view.currentIndex()
browser.view.scrollTo(index)
rect = browser.view.visualRect(index)
click_point = rect.center()
with qtbot.waitSignal(browser.view.clicked):
qtbot.mouseClick(browser.view.viewport(), Qt.LeftButton, pos=click_point)
with qtbot.waitSignal(browser.assetSelected):
qtbot.mouseDClick(browser.view.viewport(), Qt.LeftButton, pos=click_point)
def test_deletion_request(self, qtbot, test_prefix, beat_context):
browser = AssetBrowser()
qtbot.addWidget(browser)
browser.set_context(beat_context)
asset = Asset(test_prefix, AssetType.ALGORITHM, "autonomous/add/1")
browser.setCurrentAsset(asset)
index = browser.view.currentIndex()
browser.view.scrollTo(index)
rect = browser.view.visualRect(index)
click_point = rect.center()
right_click_point = browser.view.mapFromGlobal(
browser.view.viewport().mapToGlobal(click_point)
)
# customContextMenuRequested doesn't currently seem to work in testing.
# with qtbot.waitSignal(browser.view.customContextMenuRequested):
# qtbot.mouseClick(browser.view, Qt.RightButton, pos=right_click_point)
# therefore let's manually trigger the slot
browser._openMenu(right_click_point)
with qtbot.waitSignal(browser.deletionRequested):
qtbot.mouseClick(browser.contextual_menu, Qt.LeftButton)
......@@ -31,6 +31,7 @@ from PyQt5 import QtCore
from PyQt5.QtWidgets import QMessageBox
from ..widgets.assetwidget import AssetWidget
from ..widgets.assetwidget import widget_for_asset_type
from ..widgets.dialogs import AssetCreationDialog
from ..widgets.editor import PlaceholderEditor
......@@ -69,6 +70,11 @@ def asset_type_prefix_entry_map():
}
def test_widget_factory_error():
with pytest.raises(RuntimeError):
widget_for_asset_type("dummy")
class TestAssetWidget:
"""Test that the AssetWidget works correctly"""
......@@ -91,9 +97,18 @@ class TestAssetWidget:
(asset_name, editor_type),
) in asset_type_prefix_entry_map.items():
asset = Asset(test_prefix, asset_type, asset_name)
asset_widget.loadAsset(asset)
with qtbot.waitSignal(asset_widget.currentAssetChanged):
asset_widget.loadAsset(asset)
assert isinstance(asset_widget.current_editor, editor_type)
def test_prefix_root_path(self, qtbot, test_prefix, beat_context):
asset_widget = AssetWidget()
qtbot.addWidget(asset_widget)
asset_widget.set_context(beat_context)
assert asset_widget.prefix_root_path == test_prefix
def test_dirty(
self, qtbot, monkeypatch, test_prefix, beat_context, asset_type_prefix_entry_map
):
......@@ -111,7 +126,8 @@ class TestAssetWidget:
(asset_name, editor_type),
) in asset_type_prefix_entry_map.items():
asset = Asset(test_prefix, asset_type, asset_name)
asset_widget.loadAsset(asset)
with qtbot.waitSignal(asset_widget.currentAssetChanged):
asset_widget.loadAsset(asset)
assert not asset_widget.current_editor.isDirty()
with qtbot.waitSignal(asset_widget.json_widget.textChanged):
......@@ -143,7 +159,8 @@ class TestAssetWidget:
(asset_name, editor_type),
) in asset_type_prefix_entry_map.items():
asset = Asset(test_prefix, asset_type, asset_name)
asset_widget.loadAsset(asset)
with qtbot.waitSignal(asset_widget.currentAssetChanged):
asset_widget.loadAsset(asset)
with qtbot.waitSignal(asset_widget.json_widget.textChanged):
asset_widget.current_editor.description_lineedit.selectAll()
......@@ -301,7 +318,8 @@ class TestAssetWidget:
monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes)
asset = Asset(test_prefix, asset_type, asset_name)
asset_widget.loadAsset(asset)
with qtbot.waitSignal(asset_widget.currentAssetChanged):
asset_widget.loadAsset(asset)
assert asset_widget.current_editor.asset_type != AssetType.UNKNOWN