Compare commits

...

2 Commits

Author SHA1 Message Date
713d094b08 Parameter editor added 2026-07-27 12:07:47 +02:00
36051e1577 Removed AI generated test suites 2026-07-27 12:07:37 +02:00
24 changed files with 475 additions and 858 deletions

1
.gitignore vendored
View File

@@ -4,7 +4,6 @@ __pycache__/
*.egg-info/
build/
dist/
.pytest_cache/
.vscode/*
!.vscode/launch.json
!.vscode/tasks.json

27
.vscode/tasks.json vendored
View File

@@ -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": []
}
]
}
}

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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()

View 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)

View File

@@ -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())

View File

@@ -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()

View 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>

View 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()

View 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)

View File

@@ -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},
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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()
)
)