Compare commits
2 Commits
8b45674cd2
...
713d094b08
| Author | SHA1 | Date | |
|---|---|---|---|
| 713d094b08 | |||
| 36051e1577 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,7 +4,6 @@ __pycache__/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
27
.vscode/tasks.json
vendored
27
.vscode/tasks.json
vendored
@@ -1,31 +1,6 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Tests: Run pytest",
|
||||
"type": "shell",
|
||||
"command": "${command:python.interpreterPath}",
|
||||
"args": [
|
||||
"-m",
|
||||
"pytest"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/src"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"clear": true,
|
||||
"reveal": "always",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Qt: Open Designer",
|
||||
"type": "shell",
|
||||
@@ -69,4 +44,4 @@
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,8 @@ bedit = "bedit_gui.application:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"ruff>=0.5",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-config --strict-markers -ra"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"unit: fast tests without GUI event-loop interaction",
|
||||
"gui: tests that create or interact with Qt objects",
|
||||
]
|
||||
|
||||
@@ -95,7 +95,7 @@ class BondPort(Port):
|
||||
@dataclass
|
||||
class Parameter:
|
||||
name: str
|
||||
value: Any = 1.0
|
||||
value: Any = "1.0"
|
||||
value_type: ValueType = ValueType.REAL
|
||||
matrix_size: Annotated[list[int], 2] = field(default_factory=lambda: [1, 1])
|
||||
quantity: str | None = None
|
||||
|
||||
@@ -25,14 +25,18 @@ def main() -> int:
|
||||
settings = ApplicationSettings()
|
||||
document = Document(app)
|
||||
window = MainWindow()
|
||||
|
||||
LogController(window, settings.log_level)
|
||||
DocumentController(document, window)
|
||||
SettingsController(window, settings)
|
||||
UndoController(document, window)
|
||||
ViewMenuController(window)
|
||||
DocumentTreeController(document, window)
|
||||
|
||||
window_state_controller = WindowStateController(app, window)
|
||||
window_state_controller.restore()
|
||||
|
||||
document.new()
|
||||
window.showMaximized()
|
||||
|
||||
return app.exec()
|
||||
|
||||
76
src/bedit_gui/commands/param_commands.py
Normal file
76
src/bedit_gui/commands/param_commands.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_core.models import Component, Parameter, ParameterID
|
||||
|
||||
|
||||
class AddParamCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
param_id: ParameterID,
|
||||
param: Parameter,
|
||||
) -> None:
|
||||
super().__init__("Add port")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.param_id = param_id
|
||||
self.param = deepcopy(param)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
del self.component.parameters[self.param_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class RemoveParamCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
param_id: ParameterID,
|
||||
) -> None:
|
||||
super().__init__("Remove parameter")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.param_id = param_id
|
||||
self.param = deepcopy(component.parameters[param_id])
|
||||
|
||||
def redo(self) -> None:
|
||||
del self.component.parameters[self.param_id]
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
|
||||
class ChangeParamCommand(QUndoCommand):
|
||||
def __init__(
|
||||
self,
|
||||
document: object,
|
||||
component: Component,
|
||||
param_id: ParameterID,
|
||||
param: Parameter,
|
||||
) -> None:
|
||||
super().__init__("Change parameter")
|
||||
self.document = document
|
||||
self.component = component
|
||||
self.param_id = param_id
|
||||
self.old_param = deepcopy(component.parameters[param_id])
|
||||
self.new_param = deepcopy(param)
|
||||
|
||||
def redo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.new_param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
|
||||
def undo(self) -> None:
|
||||
self.component.parameters[self.param_id] = deepcopy(self.old_param)
|
||||
self.document.model_changed.emit(self.document.model)
|
||||
@@ -4,27 +4,34 @@ from typing import Protocol
|
||||
from PySide6.QtCore import QObject, QPoint, Qt
|
||||
from PySide6.QtWidgets import QDialog, QMenu
|
||||
|
||||
from bedit_core.models import Component, Port, PortID
|
||||
from bedit_core.models import Component, Port, PortID, Parameter, ParameterID
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.views.dialogs.interface_editor_dialog import (
|
||||
InterfaceEditorDialog,
|
||||
)
|
||||
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
|
||||
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
from bedit_gui.views.models.document_tree_model import DocumentTreeModel
|
||||
|
||||
|
||||
class InterfaceEditorLike(Protocol):
|
||||
def exec(self) -> int: ...
|
||||
|
||||
def ports(self) -> dict[PortID, Port]: ...
|
||||
|
||||
class ParamEditorLike(Protocol):
|
||||
def exec(self) -> int: ...
|
||||
def params(self) -> dict[ParameterID, Parameter]: ...
|
||||
|
||||
|
||||
InterfaceEditorFactory = Callable[
|
||||
[dict[PortID, Port], MainWindow],
|
||||
InterfaceEditorLike,
|
||||
]
|
||||
|
||||
ParamEditorFactory = Callable[
|
||||
[dict[ParameterID, Parameter], MainWindow],
|
||||
ParamEditorLike
|
||||
]
|
||||
|
||||
|
||||
class DocumentTreeController(QObject):
|
||||
def __init__(
|
||||
@@ -32,6 +39,7 @@ class DocumentTreeController(QObject):
|
||||
document: Document,
|
||||
window: MainWindow,
|
||||
interface_editor_factory: InterfaceEditorFactory = InterfaceEditorDialog,
|
||||
param_editor_factory: ParamEditorFactory = ParamEditorDialog,
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
|
||||
@@ -39,6 +47,7 @@ class DocumentTreeController(QObject):
|
||||
self.window = window
|
||||
self.model = DocumentTreeModel()
|
||||
self.interface_editor_factory = interface_editor_factory
|
||||
self.param_editor_factory = param_editor_factory
|
||||
|
||||
window.ui.documentTree.setModel(self.model)
|
||||
document.model_changed.connect(self._on_document_changed)
|
||||
@@ -71,11 +80,14 @@ class DocumentTreeController(QObject):
|
||||
|
||||
menu = QMenu(self.window.ui.documentTree)
|
||||
edit_interface = menu.addAction("Edit Interface")
|
||||
edit_params = menu.addAction("Edit Parameters")
|
||||
selected = menu.exec(
|
||||
self.window.ui.documentTree.viewport().mapToGlobal(position)
|
||||
)
|
||||
if selected is edit_interface:
|
||||
self._edit_interface(component)
|
||||
elif selected is edit_params:
|
||||
self._edit_params(component)
|
||||
|
||||
def _edit_interface(self, component: Component) -> None:
|
||||
dialog = self.interface_editor_factory(
|
||||
@@ -84,3 +96,12 @@ class DocumentTreeController(QObject):
|
||||
)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.document.update_component_ports(component, dialog.ports())
|
||||
|
||||
def _edit_params(self, component: Component) -> None:
|
||||
dialog = self.param_editor_factory(
|
||||
component.parameters,
|
||||
self.window
|
||||
)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.document.update_component_params(component, dialog.params())
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ from pathlib import Path
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from PySide6.QtGui import QUndoStack
|
||||
|
||||
from bedit_core.models import ID, Component, Port, PortID
|
||||
from bedit_core.models import ID, Component, Port, PortID, Parameter, ParameterID
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
|
||||
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
|
||||
from bedit_gui.commands.rename_component_command import RenameComponentCommand
|
||||
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
|
||||
from bedit_gui.services import document_files
|
||||
@@ -88,11 +89,7 @@ class Document(QObject):
|
||||
def rename_component(self, component: Component, name: str) -> None:
|
||||
self.undo_stack.push(RenameComponentCommand(self, component, name))
|
||||
|
||||
def update_component_ports(
|
||||
self,
|
||||
component: Component,
|
||||
ports: dict[PortID, Port],
|
||||
) -> None:
|
||||
def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None:
|
||||
current = component.interface.ports
|
||||
removed = [
|
||||
RemovePortCommand(self, component, port_id)
|
||||
@@ -115,3 +112,28 @@ class Document(QObject):
|
||||
for command in commands:
|
||||
self.undo_stack.push(command)
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
def update_component_params(self, component: Component, params: dict[ParameterID, Parameter]) -> None:
|
||||
current = component.parameters
|
||||
removed = [
|
||||
RemoveParamCommand(self, component, param_id)
|
||||
for param_id in current.keys() - params.keys()
|
||||
]
|
||||
added = [
|
||||
AddParamCommand(self, component, param_id, params[param_id])
|
||||
for param_id in params.keys() - current.keys()
|
||||
]
|
||||
changed = [
|
||||
ChangeParamCommand(self, component, param_id, params[param_id])
|
||||
for param_id in current.keys() & params.keys()
|
||||
if current[param_id] != params[param_id]
|
||||
]
|
||||
commands = [*removed, *added, *changed]
|
||||
if not commands:
|
||||
return
|
||||
|
||||
self.undo_stack.beginMacro("Edit parameter")
|
||||
for command in commands:
|
||||
self.undo_stack.push(command)
|
||||
self.undo_stack.endMacro()
|
||||
|
||||
|
||||
169
src/bedit_gui/ui/forms/param_editor_widget.ui
Normal file
169
src/bedit_gui/ui/forms/param_editor_widget.ui
Normal file
@@ -0,0 +1,169 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ParamEditor</class>
|
||||
<widget class="QWidget" name="ParamEditor">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>541</width>
|
||||
<height>241</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="leftColumn">
|
||||
<item>
|
||||
<widget class="QListView" name="portList"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttonRow">
|
||||
<item>
|
||||
<widget class="QPushButton" name="addParam">
|
||||
<property name="text">
|
||||
<string>Add Parameter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="removeParam">
|
||||
<property name="text">
|
||||
<string>Remove Parameter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="rightColumn">
|
||||
<item>
|
||||
<layout class="QFormLayout" name="basicForm">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="nameLabel">
|
||||
<property name="text">
|
||||
<string>Name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="nameEdit"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="typeLabel">
|
||||
<property name="text">
|
||||
<string>Type:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<layout class="QHBoxLayout" name="typeRow">
|
||||
<item>
|
||||
<widget class="QComboBox" name="typeComboBox"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="sizeLabel">
|
||||
<property name="text">
|
||||
<string>Size:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<layout class="QHBoxLayout" name="sizeRow">
|
||||
<item>
|
||||
<widget class="QSpinBox" name="widthSize">
|
||||
<property name="suffix">
|
||||
<string> rows</string>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="heightSize">
|
||||
<property name="suffix">
|
||||
<string> columns</string>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Quantity:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Unit:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLineEdit" name="valueEdit"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="valueLabel">
|
||||
<property name="text">
|
||||
<string>Value:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="descriptionForm">
|
||||
<property name="fieldGrowthPolicy">
|
||||
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="descriptionLabel">
|
||||
<property name="text">
|
||||
<string>Description:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPlainTextEdit" name="descriptionEdit">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
36
src/bedit_gui/views/dialogs/param_editor_dialog.py
Normal file
36
src/bedit_gui/views/dialogs/param_editor_dialog.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QWidget
|
||||
|
||||
from bedit_core.models import ParameterID, Parameter
|
||||
from bedit_gui.views.param_editor_widget import ParamEditorWidget
|
||||
|
||||
|
||||
class ParamEditorDialog(QDialog):
|
||||
"""Modal wrapper around the reusable param editor widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: dict[ParameterID, Parameter],
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Parameters")
|
||||
self.resize(550, 280)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.editor = ParamEditorWidget(self)
|
||||
self.editor.set_params(params)
|
||||
layout.addWidget(self.editor)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
self,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def params(self) -> dict[ParameterID, Parameter]:
|
||||
return self.editor.params()
|
||||
|
||||
134
src/bedit_gui/views/param_editor_widget.py
Normal file
134
src/bedit_gui/views/param_editor_widget.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import QItemSelection, Signal
|
||||
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from bedit_core.models import Parameter, ParameterID, ValueType
|
||||
from bedit_gui.ui.generated.ui_param_editor_widget import Ui_ParamEditor
|
||||
|
||||
|
||||
class ParamEditorWidget(QWidget):
|
||||
"""Reusable editor for a detached draft of a component's parameters."""
|
||||
|
||||
params_changed = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
self.ui = Ui_ParamEditor()
|
||||
self.ui.setupUi(self)
|
||||
self._params: dict[ParameterID, Parameter] = {}
|
||||
self._param_ids: list[ParameterID] = []
|
||||
self._loading = False
|
||||
|
||||
self.ui.typeComboBox.addItem("Real", ValueType.REAL)
|
||||
self.ui.typeComboBox.addItem("Integer", ValueType.INT)
|
||||
self.ui.typeComboBox.addItem("Boolean", ValueType.BOOL)
|
||||
|
||||
self._list_model = QStandardItemModel(self)
|
||||
self.ui.portList.setModel(self._list_model)
|
||||
self.ui.portList.selectionModel().selectionChanged.connect(self._selection_changed)
|
||||
self.ui.addParam.clicked.connect(self._add_param)
|
||||
self.ui.removeParam.clicked.connect(self._remove_param)
|
||||
|
||||
self.ui.nameEdit.textEdited.connect(self._form_changed)
|
||||
self.ui.typeComboBox.currentIndexChanged.connect(self._form_changed)
|
||||
self.ui.valueEdit.textEdited.connect(self._form_changed)
|
||||
self.ui.widthSize.valueChanged.connect(self._form_changed)
|
||||
self.ui.heightSize.valueChanged.connect(self._form_changed)
|
||||
self.ui.descriptionEdit.textChanged.connect(self._form_changed)
|
||||
|
||||
self._set_editor_enabled(False)
|
||||
|
||||
def set_params(self, params: dict[ParameterID, Parameter]) -> None:
|
||||
self._params = deepcopy(params)
|
||||
self._param_ids = list(self._params)
|
||||
self._rebuild_list()
|
||||
|
||||
def params(self) -> dict[ParameterID, Parameter]:
|
||||
return deepcopy(self._params)
|
||||
|
||||
def _rebuild_list(self, selected_id: ParameterID | None = None) -> None:
|
||||
self._list_model.clear()
|
||||
for param_id in self._param_ids:
|
||||
self._list_model.appendRow(QStandardItem(self._params[param_id].name))
|
||||
|
||||
if self._param_ids:
|
||||
if selected_id not in self._params:
|
||||
selected_id = self._param_ids[0]
|
||||
index = self._list_model.index(self._param_ids.index(selected_id), 0)
|
||||
self.ui.portList.setCurrentIndex(index)
|
||||
else:
|
||||
self._set_editor_enabled(False)
|
||||
self.ui.removeParam.setEnabled(False)
|
||||
|
||||
def _selection_changed(self, _selected: QItemSelection, _deselected: QItemSelection) -> None:
|
||||
param_id = self._selected_param_id()
|
||||
self.ui.removeParam.setEnabled(param_id is not None)
|
||||
self._set_editor_enabled(param_id is not None)
|
||||
if param_id is not None:
|
||||
self._load_param(self._params[param_id])
|
||||
|
||||
def _selected_param_id(self) -> ParameterID | None:
|
||||
index = self.ui.portList.currentIndex()
|
||||
if not index.isValid() or index.row() >= len(self._param_ids):
|
||||
return None
|
||||
return self._param_ids[index.row()]
|
||||
|
||||
def _load_param(self, param: Parameter) -> None:
|
||||
self._loading = True
|
||||
self.ui.nameEdit.setText(param.name)
|
||||
self.ui.typeComboBox.setCurrentIndex(self.ui.typeComboBox.findData(param.value_type))
|
||||
self.ui.valueEdit.setText(param.value)
|
||||
self.ui.widthSize.setValue(param.matrix_size[0])
|
||||
self.ui.heightSize.setValue(param.matrix_size[1])
|
||||
self.ui.descriptionEdit.setPlainText(param.description or "")
|
||||
self._loading = False
|
||||
|
||||
def _form_changed(self, *_args: object) -> None:
|
||||
if self._loading:
|
||||
return
|
||||
|
||||
param_id = self._selected_param_id()
|
||||
if param_id is None:
|
||||
return
|
||||
|
||||
old_param = self._params[param_id]
|
||||
self._params[param_id] = Parameter(
|
||||
name=self.ui.nameEdit.text().strip(),
|
||||
value_type=self.ui.typeComboBox.currentData(),
|
||||
value=self.ui.valueEdit.text().strip(),
|
||||
matrix_size=[self.ui.widthSize.value(), self.ui.heightSize.value()],
|
||||
quantity=old_param.quantity,
|
||||
unit=old_param.unit,
|
||||
description=self.ui.descriptionEdit.toPlainText() or None,
|
||||
)
|
||||
self._list_model.item(self._param_ids.index(param_id)).setText(self._params[param_id].name)
|
||||
self.params_changed.emit()
|
||||
|
||||
def _add_param(self) -> None:
|
||||
param_id = ParameterID()
|
||||
param = Parameter(name=f"Parameter {len(self._params) + 1}")
|
||||
self._params[param_id] = param
|
||||
self._param_ids.append(param_id)
|
||||
self._rebuild_list(param_id)
|
||||
self.params_changed.emit()
|
||||
|
||||
def _remove_param(self) -> None:
|
||||
param_id = self._selected_param_id()
|
||||
if param_id is None:
|
||||
return
|
||||
|
||||
row = self._param_ids.index(param_id)
|
||||
del self._params[param_id]
|
||||
self._param_ids.remove(param_id)
|
||||
selected = self._param_ids[min(row, len(self._param_ids) - 1)] if self._param_ids else None
|
||||
self._rebuild_list(selected)
|
||||
self.params_changed.emit()
|
||||
|
||||
def _set_editor_enabled(self, enabled: bool) -> None:
|
||||
for widget in (self.ui.nameEdit, self.ui.typeComboBox, self.ui.widthSize, self.ui.heightSize, self.ui.descriptionEdit):
|
||||
widget.setEnabled(enabled)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Shared pytest fixtures for bedit tests.
|
||||
|
||||
Qt-specific fixtures can be added here later when GUI testing starts. Keeping
|
||||
the model fixtures independent from Qt lets the core suite stay lightweight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bedit_core.models import (
|
||||
Component,
|
||||
ComponentID,
|
||||
Document,
|
||||
Graph,
|
||||
GraphImplementation,
|
||||
ID,
|
||||
Interface,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_document() -> Document:
|
||||
"""Return the smallest useful graph document for core tests."""
|
||||
root_id = ComponentID("root")
|
||||
root = Component(
|
||||
name="Root",
|
||||
interface=Interface(),
|
||||
parameters={},
|
||||
implementation=GraphImplementation(Graph()),
|
||||
)
|
||||
return Document(
|
||||
format_version=1,
|
||||
id=ID("document"),
|
||||
name="Test document",
|
||||
root={root_id: root},
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bedit_gui.controllers.document_controller import DocumentController
|
||||
from bedit_gui.controllers.log_controller import LogController
|
||||
from bedit_gui.controllers.undo_controller import UndoController
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.services.application_logging import get_logger, set_log_level
|
||||
from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
class FakeDialogs:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
def choose_open_path(self, _current_path: Path | None) -> Path:
|
||||
return self.path
|
||||
|
||||
def choose_save_path(self, _current_path: Path | None) -> Path:
|
||||
return self.path
|
||||
|
||||
def ask_save_changes(self) -> SaveChangesChoice:
|
||||
return SaveChangesChoice.DISCARD
|
||||
|
||||
def show_file_error(self, _title: str, error: Exception) -> None:
|
||||
raise AssertionError("unexpected file error") from error
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_log_level_and_qt_log_model(qt_app: QApplication) -> None:
|
||||
window = MainWindow()
|
||||
controller = LogController(window, logging.WARNING)
|
||||
logger = get_logger("tests")
|
||||
|
||||
logger.info("hidden message")
|
||||
logger.warning("visible message")
|
||||
qt_app.processEvents()
|
||||
|
||||
assert controller.model.rowCount() == 1
|
||||
assert "WARNING visible message" in controller.model.data(
|
||||
controller.model.index(0),
|
||||
Qt.ItemDataRole.DisplayRole,
|
||||
)
|
||||
|
||||
set_log_level(logging.INFO)
|
||||
logger.info("now visible")
|
||||
qt_app.processEvents()
|
||||
|
||||
assert controller.model.rowCount() == 2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_document_and_undo_actions_are_logged(
|
||||
qt_app: QApplication,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "document.json"
|
||||
window = MainWindow()
|
||||
log_controller = LogController(window)
|
||||
document = Document(qt_app)
|
||||
dialogs = FakeDialogs(path)
|
||||
document_controller = DocumentController(document, window, dialogs)
|
||||
undo_controller = UndoController(document, window)
|
||||
|
||||
document_controller.new_document()
|
||||
document_controller.save_document_as()
|
||||
document_controller.save_document()
|
||||
document_controller.open_document()
|
||||
document.undo_stack.push(QUndoCommand("test change"))
|
||||
undo_controller.undo()
|
||||
undo_controller.redo()
|
||||
qt_app.processEvents()
|
||||
|
||||
messages = [
|
||||
log_controller.model.data(log_controller.model.index(row))
|
||||
for row in range(log_controller.model.rowCount())
|
||||
]
|
||||
assert any("Created new document" in message for message in messages)
|
||||
assert any("Opened document:" in message for message in messages)
|
||||
assert any("Saved document:" in message for message in messages)
|
||||
assert any("Saved document as:" in message for message in messages)
|
||||
assert any("Undo: test change" in message for message in messages)
|
||||
assert any("Redo: test change" in message for message in messages)
|
||||
@@ -1,88 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bedit_core.bondgraph import flatten_bondgraph
|
||||
from bedit_core.models import (
|
||||
BondConnection,
|
||||
BondPort,
|
||||
Component,
|
||||
ComponentID,
|
||||
ConnectionID,
|
||||
EquationImplementation,
|
||||
Graph,
|
||||
GraphImplementation,
|
||||
Interface,
|
||||
PortID,
|
||||
SignalDirection,
|
||||
)
|
||||
|
||||
|
||||
def _leaf(name: str, port_id: PortID) -> Component:
|
||||
return Component(
|
||||
name=name,
|
||||
interface=Interface(
|
||||
{port_id: BondPort(name="p", direction=SignalDirection.INPUT)}
|
||||
),
|
||||
parameters={},
|
||||
implementation=EquationImplementation(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_flattens_bonds_across_a_component_boundary() -> None:
|
||||
boundary_id = PortID("boundary")
|
||||
inner_id = PortID("inner")
|
||||
outer_id = PortID("outer")
|
||||
inner = _leaf("Inner", inner_id)
|
||||
subsystem = Component(
|
||||
name="Subsystem",
|
||||
interface=Interface(
|
||||
{
|
||||
boundary_id: BondPort(
|
||||
name="boundary",
|
||||
direction=SignalDirection.INPUT,
|
||||
)
|
||||
}
|
||||
),
|
||||
parameters={},
|
||||
implementation=GraphImplementation(
|
||||
Graph(
|
||||
components={ComponentID("inner"): inner},
|
||||
connections={
|
||||
ConnectionID("inside"): BondConnection(
|
||||
boundary_id,
|
||||
inner_id,
|
||||
)
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
outer = _leaf("Outer", outer_id)
|
||||
root = Component(
|
||||
name="Root",
|
||||
interface=Interface(),
|
||||
parameters={},
|
||||
implementation=GraphImplementation(
|
||||
Graph(
|
||||
components={
|
||||
ComponentID("subsystem"): subsystem,
|
||||
ComponentID("outer"): outer,
|
||||
},
|
||||
connections={
|
||||
ConnectionID("outside"): BondConnection(
|
||||
outer_id,
|
||||
boundary_id,
|
||||
)
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
network = flatten_bondgraph(root)
|
||||
boundary = next(port for port in network.ports if port.port_id == boundary_id)
|
||||
|
||||
assert len(network.components) == 4
|
||||
assert len(network.ports) == 3
|
||||
assert len(network.bonds) == 2
|
||||
assert len(network.bonds_for(boundary)) == 2
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bedit_gui.controllers.document_controller import DocumentController
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
class FakeDialogs:
|
||||
def __init__(self) -> None:
|
||||
self.open_path: Path | None = None
|
||||
self.save_path: Path | None = None
|
||||
self.save_choice = SaveChangesChoice.CANCEL
|
||||
self.errors: list[tuple[str, Exception]] = []
|
||||
|
||||
def choose_open_path(self, _current_path: Path | None) -> Path | None:
|
||||
return self.open_path
|
||||
|
||||
def choose_save_path(self, _current_path: Path | None) -> Path | None:
|
||||
return self.save_path
|
||||
|
||||
def ask_save_changes(self) -> SaveChangesChoice:
|
||||
return self.save_choice
|
||||
|
||||
def show_file_error(self, title: str, error: Exception) -> None:
|
||||
self.errors.append((title, error))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_new_document_respects_unsaved_changes(qt_app: QApplication) -> None:
|
||||
document = Document(qt_app)
|
||||
window = MainWindow()
|
||||
dialogs = FakeDialogs()
|
||||
controller = DocumentController(document, window, dialogs)
|
||||
original_id = document.model.id
|
||||
document.undo_stack.push(QUndoCommand("change"))
|
||||
|
||||
controller.new_document()
|
||||
assert document.model.id == original_id
|
||||
|
||||
dialogs.save_choice = SaveChangesChoice.DISCARD
|
||||
controller.new_document()
|
||||
assert document.model.id != original_id
|
||||
assert not document.modified
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_as_then_open_document(
|
||||
qt_app: QApplication,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
document = Document(qt_app)
|
||||
window = MainWindow()
|
||||
dialogs = FakeDialogs()
|
||||
controller = DocumentController(document, window, dialogs)
|
||||
original_id = document.model.id
|
||||
path = tmp_path / "document.json"
|
||||
|
||||
dialogs.save_path = path
|
||||
assert controller.save_document_as()
|
||||
|
||||
document.new()
|
||||
dialogs.open_path = path
|
||||
controller.open_document()
|
||||
|
||||
assert document.model.id == original_id
|
||||
assert document.path == path
|
||||
assert not dialogs.errors
|
||||
@@ -1,14 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bedit_core.models import Document
|
||||
from bedit_util.graphviz import document_to_dot
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_dot_contains_component_name_path(minimal_document: Document) -> None:
|
||||
dot = document_to_dot(minimal_document, "Root")
|
||||
|
||||
assert 'label="Root"' in dot
|
||||
assert 'xlabel="Root"' in dot
|
||||
@@ -1,37 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QUndoCommand
|
||||
|
||||
from bedit_gui.documents import Document
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("suffix", [".beb", ".json"])
|
||||
def test_document_save_and_open(tmp_path: Path, suffix: str) -> None:
|
||||
path = tmp_path / f"document{suffix}"
|
||||
document = Document()
|
||||
original_id = document.model.id
|
||||
|
||||
document.save_as(path)
|
||||
document.new()
|
||||
assert document.model.id != original_id
|
||||
|
||||
document.open(path)
|
||||
|
||||
assert document.model.id == original_id
|
||||
assert document.path == path
|
||||
assert not document.modified
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_document_modified_state_uses_undo_stack() -> None:
|
||||
document = Document()
|
||||
|
||||
document.undo_stack.push(QUndoCommand("change"))
|
||||
assert document.modified
|
||||
|
||||
document.undo_stack.setClean()
|
||||
assert not document.modified
|
||||
@@ -1,14 +0,0 @@
|
||||
"""Basic smoke tests for the core model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bedit_core.models import Document, GraphImplementation
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimal_document_has_graph_root(minimal_document: Document) -> None:
|
||||
root = next(iter(minimal_document.root.values()))
|
||||
|
||||
assert isinstance(root.implementation, GraphImplementation)
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bedit_core import models
|
||||
from bedit_core.models import (
|
||||
BondPort,
|
||||
Component,
|
||||
Interface,
|
||||
PortID,
|
||||
SignalDirection,
|
||||
SignalPort,
|
||||
ValueType,
|
||||
)
|
||||
from bedit_gui.documents import Document
|
||||
from bedit_gui.views.port_editor_widget import PortEditorWidget
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_port_editor_uses_a_detached_draft(qt_app: QApplication) -> None:
|
||||
port_id = PortID("input")
|
||||
original = {
|
||||
port_id: SignalPort(
|
||||
name="Input",
|
||||
direction=SignalDirection.INPUT,
|
||||
)
|
||||
}
|
||||
editor = PortEditorWidget()
|
||||
editor.set_ports(original)
|
||||
|
||||
editor.ui.nameEdit.setText("Changed")
|
||||
editor.ui.nameEdit.textEdited.emit("Changed")
|
||||
|
||||
assert original[port_id].name == "Input"
|
||||
assert editor.ports()[port_id].name == "Changed"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_port_type_controls_option_visibility(qt_app: QApplication) -> None:
|
||||
editor = PortEditorWidget()
|
||||
editor.set_ports(
|
||||
{
|
||||
PortID("port"): SignalPort(
|
||||
name="Port",
|
||||
direction=SignalDirection.INPUT,
|
||||
value_type=ValueType.REAL,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assert not editor.ui.signalTypeComboBox.isHidden()
|
||||
assert editor.ui.domainComboBox.isHidden()
|
||||
|
||||
editor.ui.typeBond.setChecked(True)
|
||||
|
||||
assert editor.ui.signalTypeComboBox.isHidden()
|
||||
assert not editor.ui.domainComboBox.isHidden()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bond_causality_is_loaded_and_changed(qt_app: QApplication) -> None:
|
||||
port_id = PortID("bond")
|
||||
editor = PortEditorWidget()
|
||||
editor.set_ports({port_id: BondPort("Bond", SignalDirection.INPUT, causality_preference=models.PortCausality.PREFERRED_FLOW_OUT)})
|
||||
|
||||
assert editor.ui.causalityComboBox.currentData() is models.PortCausality.PREFERRED_FLOW_OUT
|
||||
|
||||
index = editor.ui.causalityComboBox.findData(models.PortCausality.FIXED_EFFORT_OUT)
|
||||
editor.ui.causalityComboBox.setCurrentIndex(index)
|
||||
|
||||
assert editor.ports()[port_id].causality_preference is models.PortCausality.FIXED_EFFORT_OUT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_port_changes_are_one_undoable_operation(
|
||||
qt_app: QApplication,
|
||||
minimal_document,
|
||||
) -> None:
|
||||
component: Component = next(iter(minimal_document.root.values()))
|
||||
removed_id = PortID("removed")
|
||||
changed_id = PortID("changed")
|
||||
added_id = PortID("added")
|
||||
component.interface = Interface(
|
||||
{
|
||||
removed_id: SignalPort("Remove", SignalDirection.INPUT),
|
||||
changed_id: SignalPort("Before", SignalDirection.INPUT),
|
||||
}
|
||||
)
|
||||
original = deepcopy(component.interface.ports)
|
||||
updated = {
|
||||
changed_id: BondPort(
|
||||
"After",
|
||||
SignalDirection.OUTPUT,
|
||||
domain="electrical",
|
||||
),
|
||||
added_id: SignalPort("Added", SignalDirection.INPUT),
|
||||
}
|
||||
document = Document(qt_app)
|
||||
|
||||
document.update_component_ports(component, updated)
|
||||
assert component.interface.ports == updated
|
||||
|
||||
document.undo_stack.undo()
|
||||
assert component.interface.ports == original
|
||||
|
||||
document.undo_stack.redo()
|
||||
assert component.interface.ports == updated
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Basic smoke tests for document serialization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bedit_core.models import Document
|
||||
from bedit_core.serialization import load, save
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_json_round_trip(tmp_path, minimal_document: Document) -> None:
|
||||
path = tmp_path / "document.json"
|
||||
|
||||
save(minimal_document, path)
|
||||
|
||||
assert load(path) == minimal_document
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QSettings
|
||||
from PySide6.QtWidgets import QApplication, QDialog
|
||||
|
||||
from bedit_gui.controllers.settings_controller import SettingsController
|
||||
from bedit_gui.services.application_logging import get_logger
|
||||
from bedit_gui.services.application_settings import ApplicationSettings
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
class FakeSettingsDialog:
|
||||
def __init__(self, log_level: int) -> None:
|
||||
self.initial_log_level = log_level
|
||||
self.log_level = logging.ERROR
|
||||
|
||||
def exec(self) -> QDialog.DialogCode:
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_log_level_is_persisted(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.ini"
|
||||
backend = QSettings(str(path), QSettings.Format.IniFormat)
|
||||
settings = ApplicationSettings(backend)
|
||||
|
||||
settings.log_level = logging.DEBUG
|
||||
backend.sync()
|
||||
|
||||
reloaded = ApplicationSettings(
|
||||
QSettings(str(path), QSettings.Format.IniFormat)
|
||||
)
|
||||
assert reloaded.log_level == logging.DEBUG
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_settings_action_applies_log_level(
|
||||
qt_app: QApplication,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
backend = QSettings(
|
||||
str(tmp_path / "settings.ini"),
|
||||
QSettings.Format.IniFormat,
|
||||
)
|
||||
settings = ApplicationSettings(backend)
|
||||
window = MainWindow()
|
||||
dialogs: list[FakeSettingsDialog] = []
|
||||
|
||||
def make_dialog(
|
||||
log_level: int,
|
||||
_window: MainWindow,
|
||||
) -> FakeSettingsDialog:
|
||||
dialog = FakeSettingsDialog(log_level)
|
||||
dialogs.append(dialog)
|
||||
return dialog
|
||||
|
||||
SettingsController(window, settings, make_dialog)
|
||||
window.ui.actionSettings.trigger()
|
||||
|
||||
assert dialogs[0].initial_log_level == logging.INFO
|
||||
assert settings.log_level == logging.ERROR
|
||||
assert get_logger("tests").isEnabledFor(logging.ERROR)
|
||||
assert not get_logger("tests").isEnabledFor(logging.WARNING)
|
||||
@@ -1,174 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from bedit_simulation import (
|
||||
OpenModelicaRunner,
|
||||
ProcessResult,
|
||||
Simulation,
|
||||
SimulationOptions,
|
||||
SimulationStateError,
|
||||
)
|
||||
|
||||
|
||||
def _fake_omc(
|
||||
command: Sequence[str],
|
||||
working_directory: Path,
|
||||
timeout: float | None,
|
||||
environment: Mapping[str, str] | None,
|
||||
) -> ProcessResult:
|
||||
del timeout, environment
|
||||
script = Path(command[-1]).read_text(encoding="utf-8")
|
||||
if "buildModel(Example" in script:
|
||||
(working_directory / "Example").touch()
|
||||
if "system(" in script:
|
||||
port_match = re.search(r"-port=(\d+)", script)
|
||||
assert port_match is not None
|
||||
with socket.create_connection(
|
||||
("127.0.0.1", int(port_match.group(1)))
|
||||
) as connection:
|
||||
connection.sendall(
|
||||
b'<status phase="integration" currentStepSize="0.1" '
|
||||
b'time="1" progress="50"/>\n'
|
||||
)
|
||||
(working_directory / "Example_res.csv").write_text(
|
||||
'"time","x"\n0,1\n1,2\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return ProcessResult(
|
||||
tuple(command),
|
||||
0,
|
||||
'"Check of Example completed successfully.\n'
|
||||
'Class Example has 1 equation(s) and 1 variable(s)."\n'
|
||||
'""\n',
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_runs_omc_through_configured_command(tmp_path: Path) -> None:
|
||||
runner = OpenModelicaRunner(
|
||||
["./run-omc-in-docker"],
|
||||
executor=_fake_omc,
|
||||
)
|
||||
|
||||
async def run() -> object:
|
||||
simulation = Simulation(runner)
|
||||
await simulation.load_modelica(
|
||||
"model Example\nend Example;\n",
|
||||
"Example",
|
||||
)
|
||||
await simulation.build(tmp_path)
|
||||
return await simulation.run(
|
||||
SimulationOptions(stop_time=2, number_of_intervals=20),
|
||||
)
|
||||
|
||||
result = asyncio.run(run())
|
||||
|
||||
assert result.data == {"time": [0.0, 1.0], "x": [1.0, 2.0]}
|
||||
assert result.result_file == tmp_path / "Example_res.csv"
|
||||
script = (tmp_path / "run.mos").read_text(encoding="utf-8")
|
||||
assert f'cd("{tmp_path}")' in script
|
||||
assert "stepSize=0.1" in script
|
||||
assert "-logFormat=xmltcp" in script
|
||||
assert "simulate(Example" not in script
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_invalid_time_range() -> None:
|
||||
with pytest.raises(ValueError, match="stop_time"):
|
||||
SimulationOptions(start_time=1, stop_time=1)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_run_requires_a_built_model() -> None:
|
||||
simulation = Simulation(OpenModelicaRunner("omc", executor=_fake_omc))
|
||||
|
||||
async def run() -> None:
|
||||
await simulation.load_modelica(
|
||||
"model Example\nend Example;\n",
|
||||
"Example",
|
||||
)
|
||||
with pytest.raises(SimulationStateError, match=r"call build\(\) first"):
|
||||
await simulation.run()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_checks_existing_modelica_source(tmp_path: Path) -> None:
|
||||
simulation = Simulation(OpenModelicaRunner("omc", executor=_fake_omc))
|
||||
|
||||
async def check() -> object:
|
||||
await simulation.load_modelica(
|
||||
"model Example\nend Example;\n",
|
||||
"Example",
|
||||
)
|
||||
return await simulation.check(working_directory=tmp_path)
|
||||
|
||||
result = asyncio.run(check())
|
||||
|
||||
assert result.successful
|
||||
assert result.output == [
|
||||
"Check of Example completed successfully.",
|
||||
"Class Example has 1 equation(s) and 1 variable(s).",
|
||||
"",
|
||||
]
|
||||
script = (tmp_path / "model-command.mos").read_text(encoding="utf-8")
|
||||
assert "checkModel(Example);" in script
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_async_execution_uses_a_worker_thread(tmp_path: Path) -> None:
|
||||
caller_thread = threading.get_ident()
|
||||
execution_threads: list[int] = []
|
||||
|
||||
def executor(
|
||||
command: Sequence[str],
|
||||
working_directory: Path,
|
||||
timeout: float | None,
|
||||
environment: Mapping[str, str] | None,
|
||||
) -> ProcessResult:
|
||||
del working_directory, timeout, environment
|
||||
execution_threads.append(threading.get_ident())
|
||||
return ProcessResult(tuple(command), 0, '"OpenModelica test"', "")
|
||||
|
||||
simulation = Simulation(OpenModelicaRunner("omc", executor=executor))
|
||||
|
||||
result = asyncio.run(
|
||||
simulation.execute(
|
||||
"getVersion()",
|
||||
working_directory=tmp_path,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.return_code == 0
|
||||
assert len(execution_threads) == 1
|
||||
assert execution_threads[0] != caller_thread
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_requires_and_retains_a_loaded_model(tmp_path: Path) -> None:
|
||||
simulation = Simulation(OpenModelicaRunner("omc", executor=_fake_omc))
|
||||
|
||||
async def use_session() -> None:
|
||||
with pytest.raises(SimulationStateError, match="no model is loaded"):
|
||||
await simulation.check()
|
||||
|
||||
await simulation.load_modelica(
|
||||
"model Example\nend Example;\n",
|
||||
"Example",
|
||||
)
|
||||
check = await simulation.check(working_directory=tmp_path)
|
||||
|
||||
assert simulation.model_name == "Example"
|
||||
assert simulation.last_check is check
|
||||
|
||||
asyncio.run(use_session())
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import matplotlib
|
||||
import pytest
|
||||
|
||||
from bedit_simulation import SimulationResult
|
||||
from bedit_util.simulate import create_results_figure
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_creates_result_figure_with_trace_controls() -> None:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
result = SimulationResult(
|
||||
model_name="Example",
|
||||
data={
|
||||
"time": [0.0, 1.0],
|
||||
"x": [1.0, 2.0],
|
||||
"y": [3.0, 4.0],
|
||||
},
|
||||
)
|
||||
|
||||
figure = create_results_figure(result)
|
||||
|
||||
assert {line.get_label() for line in figure.axes[0].lines} == {"x", "y"}
|
||||
assert len(figure._bedit_widgets) == 3 # type: ignore[attr-defined]
|
||||
plt.close(figure)
|
||||
@@ -1,38 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bedit_gui.controllers.view_menu_controller import ViewMenuController
|
||||
from bedit_gui.views.main_window import MainWindow
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_view_submenus_contain_visibility_actions(
|
||||
qt_app: QApplication,
|
||||
) -> None:
|
||||
window = MainWindow()
|
||||
controller = ViewMenuController(window)
|
||||
|
||||
assert window.ui.actionPanels.menu() is controller.panels_menu
|
||||
assert window.ui.actionToolbars.menu() is controller.toolbars_menu
|
||||
assert [action.text() for action in controller.panels_menu.actions()] == [
|
||||
"Document Tree",
|
||||
"Log",
|
||||
]
|
||||
assert [action.text() for action in controller.toolbars_menu.actions()] == [
|
||||
"File",
|
||||
"Undo",
|
||||
]
|
||||
assert all(
|
||||
action.isCheckable()
|
||||
for action in (
|
||||
controller.panels_menu.actions()
|
||||
+ controller.toolbars_menu.actions()
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user