From fd6a21eac69bc781e71f47fcb5d704bad70f9c89 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 1 Oct 2026 22:56:18 +0200 Subject: [PATCH 01/19] Exposed: the modal line and an action's keys to readers outside them --- .../utils/gui/modal_queue.py | 34 ++++++++++- .../utils/gui/shortcuts/manager.py | 4 ++ .../utils/gui/shortcuts/test_manager.py | 23 ++++++++ .../utils/gui/test_modal_queue.py | 56 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py index 38dac23b2..f503d6f13 100644 --- a/src/sampletones_application/utils/gui/modal_queue.py +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -1,6 +1,6 @@ from collections import deque from dataclasses import dataclass -from typing import ClassVar, Deque, Dict, List, Optional +from typing import ClassVar, Deque, Dict, List, Optional, Tuple from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.meta import NonInstantiableMeta @@ -20,6 +20,28 @@ class WaitingModal: build: VoidCallback +@dataclass(frozen=True) +class ModalQueueSnapshot: + """What the line holds at one moment, for a reader watching the screen from outside it. + + Attributes: + shown: The window standing on the screen, or ``None`` while the screen is free. + aside: The windows standing aside for the modals they handed the screen to. + waiting: The windows waiting for the screen, in the order they open. + turning: Whether the line moves on in a coming frame. + """ + + shown: Optional[str] + aside: Tuple[str, ...] + waiting: Tuple[str, ...] + turning: bool + + @property + def is_settled(self) -> bool: + """Whether the line stands still: nothing waits, nothing stands aside, and no turn is due.""" + return not self.aside and not self.waiting and not self.turning + + class ModalQueue(metaclass=NonInstantiableMeta): """The one modal window DearPyGui shows at a time, and the modal windows waiting for the screen. @@ -117,6 +139,16 @@ def hand_off(cls, continuation: VoidCallback) -> None: cls._hand_offs.append(continuation) cls._take_a_turn() + @classmethod + def snapshot(cls) -> ModalQueueSnapshot: + """What the line holds now: the window standing, the ones aside and the ones waiting.""" + return ModalQueueSnapshot( + shown=cls._shown, + aside=tuple(cls._aside), + waiting=tuple(waiting.tag for waiting in cls._waiting), + turning=cls._turn_due, + ) + @classmethod def clear(cls) -> None: """Forgets every window, which is where a DearPyGui context that was just made starts from.""" diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 9cb137288..22ad15516 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -40,6 +40,10 @@ def register(self, shortcut_id: ShortcutId, callback: Callback) -> None: """Names the call an action makes when its combination is pressed or its menu item chosen.""" self._callbacks[shortcut_id] = callback + def shortcut(self, shortcut_id: ShortcutId) -> Shortcut: + """The binding an action answers to under the scheme in place, rebinds included.""" + return self._source.shortcut(shortcut_id) + def add_menu_item( self, shortcut_id: ShortcutId, diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py index 71eb277cb..417cdc248 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -95,6 +95,29 @@ def test_an_action_the_scheme_leaves_unassigned_answers_no_press(self, source: S callback.assert_not_called() +class TestShortcutLookup: + """A reader outside the dispatch asks the manager which keys an action answers to.""" + + def test_the_binding_read_is_the_one_the_scheme_gives(self, source: ShortcutSource) -> None: + manager = _manager(source, ShortcutId.SAVE_PROJECT, Mock()) + + shortcut = manager.shortcut(ShortcutId.SAVE_PROJECT) + + assert shortcut == source.shortcut(ShortcutId.SAVE_PROJECT) + + def test_the_binding_read_follows_a_rebind( + self, + source: ShortcutSource, + rebound: RebindScheme, + ) -> None: + manager = _manager(source, ShortcutId.SAVE_PROJECT, Mock()) + + source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")})) + manager.rebind() + + assert manager.shortcut(ShortcutId.SAVE_PROJECT).matches(_event(dpg.mvKey_K, modifiers=CTRL_ALT)) + + class TestRebind: """A registration names the action, so activating another scheme changes the keys that fire it.""" diff --git a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py index 62be65bed..61ecd9d8e 100644 --- a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -254,3 +254,59 @@ def answer() -> None: held_frames.render() assert screen.built == [DIALOG, PROMPT, SECOND] + + +class TestTheSnapshot: + """A reader outside the line sees which window stands, which wait, and whether the line moves.""" + + def test_a_free_screen_reads_settled(self) -> None: + snapshot = ModalQueue.snapshot() + + assert snapshot.shown is None + assert snapshot.is_settled + + def test_the_standing_window_and_the_line_read_in_order(self, screen: Screen) -> None: + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + snapshot = ModalQueue.snapshot() + + assert snapshot.shown == FIRST + assert snapshot.waiting == (SECOND, THIRD) + assert not snapshot.is_settled + + @pytest.mark.usefixtures("held_frames") + def test_a_window_that_left_reads_as_a_turn_due(self, screen: Screen) -> None: + screen.open(FIRST) + + ModalQueue.leave(FIRST) + + snapshot = ModalQueue.snapshot() + assert snapshot.shown is None + assert snapshot.turning + assert not snapshot.is_settled + + def test_the_line_settles_a_frame_after_the_last_window_left( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + ModalQueue.leave(FIRST) + + held_frames.render() + + assert ModalQueue.snapshot().is_settled + + def test_a_dialog_standing_aside_reads_aside(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + snapshot = ModalQueue.snapshot() + + assert snapshot.shown == PROMPT + assert snapshot.aside == (DIALOG,) + assert not snapshot.is_settled From dbaa8acd35e11cfd16b553d679b0bdcfc7949c71 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 1 Oct 2026 23:29:43 +0200 Subject: [PATCH 02/19] Built: a driver that runs SampleToNES on a display of its own, a scenario per process --- pyproject.toml | 3 + .../integration/tooling/test_screen_layers.py | 50 ++++ tests/screens/__init__.py | 0 tests/screens/conftest.py | 27 ++ tests/suite/screens/__init__.py | 0 tests/suite/screens/application.py | 160 ++++++++++++ tests/suite/screens/boundaries/__init__.py | 0 tests/suite/screens/boundaries/audio.py | 48 ++++ tests/suite/screens/boundaries/dialogs.py | 121 +++++++++ tests/suite/screens/boundaries/errors.py | 41 +++ tests/suite/screens/boundaries/spawns.py | 77 ++++++ tests/suite/screens/checks.py | 65 +++++ tests/suite/screens/dearpygui/__init__.py | 0 tests/suite/screens/dearpygui/bridge.py | 132 ++++++++++ tests/suite/screens/dearpygui/display.py | 68 +++++ tests/suite/screens/dearpygui/geometry.py | 53 ++++ tests/suite/screens/dearpygui/hand.py | 166 ++++++++++++ tests/suite/screens/dearpygui/hosting.py | 89 +++++++ tests/suite/screens/dearpygui/isolation.py | 184 +++++++++++++ tests/suite/screens/dearpygui/items.py | 193 ++++++++++++++ tests/suite/screens/dearpygui/keys.py | 87 +++++++ tests/suite/screens/dearpygui/reach.py | 70 +++++ tests/suite/screens/dearpygui/screenshot.py | 24 ++ tests/suite/screens/dearpygui/semantic.py | 60 +++++ tests/suite/screens/dearpygui/xtest.py | 68 +++++ tests/suite/screens/environment.py | 89 +++++++ tests/suite/screens/keyboard.py | 23 ++ tests/suite/screens/paths.py | 12 + tests/suite/screens/plugin.py | 242 ++++++++++++++++++ tests/suite/screens/render_thread.py | 40 +++ tests/suite/screens/screen.py | 117 +++++++++ tests/suite/screens/views/__init__.py | 0 tests/suite/screens/views/display_settings.py | 50 ++++ tests/suite/screens/views/menus.py | 57 +++++ tests/suite/screens/views/project.py | 71 +++++ tests/suite/screens/views/prompts.py | 60 +++++ tests/suite/screens/views/tabs.py | 41 +++ uv.lock | 45 ++++ 38 files changed, 2633 insertions(+) create mode 100644 tests/integration/tooling/test_screen_layers.py create mode 100644 tests/screens/__init__.py create mode 100644 tests/screens/conftest.py create mode 100644 tests/suite/screens/__init__.py create mode 100644 tests/suite/screens/application.py create mode 100644 tests/suite/screens/boundaries/__init__.py create mode 100644 tests/suite/screens/boundaries/audio.py create mode 100644 tests/suite/screens/boundaries/dialogs.py create mode 100644 tests/suite/screens/boundaries/errors.py create mode 100644 tests/suite/screens/boundaries/spawns.py create mode 100644 tests/suite/screens/checks.py create mode 100644 tests/suite/screens/dearpygui/__init__.py create mode 100644 tests/suite/screens/dearpygui/bridge.py create mode 100644 tests/suite/screens/dearpygui/display.py create mode 100644 tests/suite/screens/dearpygui/geometry.py create mode 100644 tests/suite/screens/dearpygui/hand.py create mode 100644 tests/suite/screens/dearpygui/hosting.py create mode 100644 tests/suite/screens/dearpygui/isolation.py create mode 100644 tests/suite/screens/dearpygui/items.py create mode 100644 tests/suite/screens/dearpygui/keys.py create mode 100644 tests/suite/screens/dearpygui/reach.py create mode 100644 tests/suite/screens/dearpygui/screenshot.py create mode 100644 tests/suite/screens/dearpygui/semantic.py create mode 100644 tests/suite/screens/dearpygui/xtest.py create mode 100644 tests/suite/screens/environment.py create mode 100644 tests/suite/screens/keyboard.py create mode 100644 tests/suite/screens/paths.py create mode 100644 tests/suite/screens/plugin.py create mode 100644 tests/suite/screens/render_thread.py create mode 100644 tests/suite/screens/screen.py create mode 100644 tests/suite/screens/views/__init__.py create mode 100644 tests/suite/screens/views/display_settings.py create mode 100644 tests/suite/screens/views/menus.py create mode 100644 tests/suite/screens/views/project.py create mode 100644 tests/suite/screens/views/prompts.py create mode 100644 tests/suite/screens/views/tabs.py diff --git a/pyproject.toml b/pyproject.toml index 01b240ac7..0adf04fc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,10 @@ dev = [ "pytest==9.1.1", "pytest-cov==7.1.0", "pytest-xdist==3.8.0", + "python-xlib==0.33", + "pyvirtualdisplay==3.0", "types-PyYAML==6.0.12.20260518", + "types-python-xlib==0.33.0.20260724", ] [build-system] diff --git a/tests/integration/tooling/test_screen_layers.py b/tests/integration/tooling/test_screen_layers.py new file mode 100644 index 000000000..f757f03de --- /dev/null +++ b/tests/integration/tooling/test_screen_layers.py @@ -0,0 +1,50 @@ +import ast +import sys +from pathlib import Path +from typing import Final, FrozenSet, Iterator + +from tests.suite.screens.paths import DEARPYGUI_LAYER_DIRECTORY + +LAYER_PACKAGE: Final[str] = "tests.suite.screens.dearpygui" +MODULE_SEPARATOR: Final[str] = "." +THIRD_PARTY: Final[FrozenSet[str]] = frozenset({"dearpygui", "pytest", "pyvirtualdisplay", "Xlib"}) + + +def imported_modules(path: Path) -> Iterator[str]: + """Every module ``path`` imports by its absolute name.""" + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + yield from (alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None: + yield node.module + + +def is_allowed(module: str) -> bool: + top = module.split(MODULE_SEPARATOR, 1)[0] + return ( + top in sys.stdlib_module_names + or top in THIRD_PARTY + or module == LAYER_PACKAGE + or module.startswith(LAYER_PACKAGE + MODULE_SEPARATOR) + ) + + +class TestTheDearPyGuiLayer: + """The screen tier's DearPyGui layer knows DearPyGui and pytest, and nothing of SampleToNES. + + The layer drives any DearPyGui application, and SampleToNES builds on it from outside, so the + layer moves to a project of its own as it stands the day a second application wants it. + """ + + def test_every_import_is_dearpygui_pytest_x11_or_the_standard_library(self) -> None: + strays = [ + f"{path.name} imports {module}" + for path in sorted(DEARPYGUI_LAYER_DIRECTORY.glob("*.py")) + for module in imported_modules(path) + if not is_allowed(module) + ] + + assert strays == [] + + def test_the_layer_holds_modules_to_read(self) -> None: + assert any(DEARPYGUI_LAYER_DIRECTORY.glob("*.py")) diff --git a/tests/screens/__init__.py b/tests/screens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py new file mode 100644 index 000000000..478697f23 --- /dev/null +++ b/tests/screens/conftest.py @@ -0,0 +1,27 @@ +from tests.suite.screens.plugin import ( + output_device, + pytest_configure, + pytest_pyfunc_call, + pytest_runtest_protocol, + pytest_unconfigure, + screen, + screen_application, + screen_boundaries, + screen_bridge, + screen_render_thread, + startup, +) + +__all__ = [ + "output_device", + "pytest_configure", + "pytest_pyfunc_call", + "pytest_runtest_protocol", + "pytest_unconfigure", + "screen", + "screen_application", + "screen_boundaries", + "screen_bridge", + "screen_render_thread", + "startup", +] diff --git a/tests/suite/screens/__init__.py b/tests/suite/screens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application.py new file mode 100644 index 000000000..84cae1b2e --- /dev/null +++ b/tests/suite/screens/application.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.application import Application +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.utils.gui.keyboard import KeyEvent +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.checks import ( + AfterCheckError, + contained_findings, + quiet_findings, + read_screen_state, + screen_findings, +) +from tests.suite.screens.dearpygui.bridge import Bridge, RenderThreadStoppedError +from tests.suite.screens.dearpygui.screenshot import capture +from tests.suite.screens.keyboard import primary_combination +from tests.suite.screens.render_thread import QueueRenderThread + +EXIT_TIMEOUT_SECONDS: Final[float] = 15.0 + + +@dataclass(frozen=True) +class Startup: + """The documents a scenario's application opens as it starts, as ``sampletones open`` hands them over. + + Attributes: + reconstruction: The reconstruction open on the Reconstructions tab, if any. + project: The project open in the Sequencer, if any. + """ + + reconstruction: Optional[Path] + project: Optional[Path] + + +NOTHING_TO_OPEN: Final[Startup] = Startup( + reconstruction=None, + project=None, +) + + +@dataclass(frozen=True) +class Boundaries: + """What stands between a scenario's application and the desktop around it.""" + + dialogs: ScriptedFileDialogs + errors: ErrorRecords + spawns: SpawnGuard + + +class ScreenApplication: + """SampleToNES running for one scenario: its loop on the main thread, its ending read by the after-checks. + + A scenario that ends well leaves through the Exit shortcut, so every question the application asks + about unsaved work runs as it would for a user, and the application must then stop at once. A + scenario that failed, or a screen the after-checks object to, stops the loop outright once a + screenshot of it is kept. + """ + + def __init__( + self, + application: Application, + *, + render_thread: QueueRenderThread, + bridge: Bridge, + boundaries: Boundaries, + failure_screenshot: Path, + state_path: Path, + ) -> None: + self._application = application + self._render_thread = render_thread + self._bridge = bridge + self._boundaries = boundaries + self._failure_screenshot = failure_screenshot + self._state_path = state_path + self._reported_errors = 0 + + @property + def language(self) -> LanguageManager: + return self._application.language_manager + + @property + def shortcuts(self) -> ShortcutManager: + return self._application.shortcut_manager + + def run(self) -> None: + self._boundaries.spawns.arm() + self._render_thread.start() + try: + self._application.run() + finally: + self._render_thread.stop() + + def finish(self, *, failed: bool) -> None: + findings = self._boundary_findings() + if self._render_thread.is_running(): + findings.extend(self._screen_findings()) + if failed or findings: + findings.extend(self._capture_failure()) + self._halt() + elif not self._exit(): + findings.append("Leaving the application asked a question the scenario never answered") + findings.extend(self._capture_failure()) + self._halt() + + if findings: + raise AfterCheckError("\n".join(findings)) + + def close(self) -> None: + findings = quiet_findings(self._boundaries.errors)[self._reported_errors :] + if not self._state_path.exists(): + findings.append(f"The application stopped without writing its session state to {self._state_path}") + + if findings: + raise AfterCheckError("\n".join(findings)) + + def _boundary_findings(self) -> List[str]: + quiet = quiet_findings(self._boundaries.errors) + self._reported_errors = len(quiet) + return [ + *quiet, + *contained_findings(self._boundaries.spawns, self._boundaries.dialogs), + ] + + def _screen_findings(self) -> List[str]: + try: + state = self._bridge.ask(read_screen_state) + except RenderThreadStoppedError: + return [] + + return screen_findings(state) + + def _exit(self) -> bool: + combination = primary_combination(self._application.shortcut_manager.shortcut(ShortcutId.EXIT)) + event = KeyEvent(key=combination.key, modifiers=combination.modifiers) + self._bridge.ask(lambda: self._application.key_router.route(event)) + return self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) + + def _halt(self) -> None: + try: + self._bridge.ask(dpg.stop_dearpygui) + except RenderThreadStoppedError: + return + + self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) + + def _capture_failure(self) -> List[str]: + try: + capture(self._bridge, self._failure_screenshot) + except AssertionError as error: + return [f"The failure screenshot could not be taken: {error}"] + + return [] diff --git a/tests/suite/screens/boundaries/__init__.py b/tests/suite/screens/boundaries/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/boundaries/audio.py b/tests/suite/screens/boundaries/audio.py new file mode 100644 index 000000000..e7f608bab --- /dev/null +++ b/tests/suite/screens/boundaries/audio.py @@ -0,0 +1,48 @@ +from enum import StrEnum +from pathlib import Path +from typing import Final + +import pyaudio +import pytest + +ALSA_CONFIGURATION_FILE: Final[str] = ".asoundrc" +SILENT_DEFAULT_DEVICE: Final[str] = "pcm.!default {\n type null\n}\n" + + +class OutputDevice(StrEnum): + """The output a scenario's machine offers: a device that plays into silence, or none at all.""" + + SILENT = "silent" + NONE = "none" + + +def provide_output_device( + device: OutputDevice, + *, + home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Gives the application the output ``device`` before it starts.""" + match device: + case OutputDevice.SILENT: + _silent_output_device(home) + case OutputDevice.NONE: + _no_output_device(monkeypatch) + + +def _silent_output_device(home: Path) -> None: + """Points ALSA's default device at a sink that consumes sound in real time and plays none of it. + + Playback runs as it does for a user, its stream and its cursor included, and nothing is heard. + """ + (home / ALSA_CONFIGURATION_FILE).write_text(SILENT_DEFAULT_DEVICE, encoding="utf-8") + + +def _no_output_device(monkeypatch: pytest.MonkeyPatch) -> None: + """Starts the application on a machine offering no output device at all.""" + monkeypatch.setattr(pyaudio.PyAudio, "get_device_count", lambda _: 0) + monkeypatch.setattr(pyaudio.PyAudio, "get_default_output_device_info", _no_default_device) + + +def _no_default_device(_: pyaudio.PyAudio) -> None: + raise OSError("A screen scenario offers no output device") diff --git a/tests/suite/screens/boundaries/dialogs.py b/tests/suite/screens/boundaries/dialogs.py new file mode 100644 index 000000000..545e3f04f --- /dev/null +++ b/tests/suite/screens/boundaries/dialogs.py @@ -0,0 +1,121 @@ +import threading +from collections import deque +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Deque, Dict, List, Optional, Tuple + +import pytest + +import sampletones_application.utils.file_dialogs.api as file_dialogs_api +from sampletones_application.utils.file_dialogs.destination import SaveDestination, untyped_destination +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +class DialogKind(StrEnum): + OPEN = "open" + SAVE = "save" + DIRECTORY = "directory" + + +@dataclass(frozen=True) +class DialogRequest: + """One file dialog the application opened during a scenario. + + Attributes: + kind: What the dialog asked for. + title: The title the dialog carried. + answered: Whether the scenario had queued an answer for it. + answer: The path the dialog answered with, or ``None`` for a dismissed dialog. + """ + + kind: DialogKind + title: str + answered: bool + answer: Optional[Path] + + +class ScriptedFileDialogs: + """The file dialogs a scenario stands behind: each answers with what the scenario queued for its kind. + + A native dialog stops the queue the render thread drains while it stands, so a dialog here answers + at once from the answers queued before the gesture that opens it. A dialog opening with nothing + queued is dismissed, and the after-checks report it as a question the scenario left unanswered. + """ + + def __init__(self) -> None: + self._answers: Dict[DialogKind, Deque[Optional[Path]]] = {kind: deque() for kind in DialogKind} + self._requests: List[DialogRequest] = [] + self._lock = threading.Lock() + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Stands in for the native backend every file dialog of the application asks for.""" + monkeypatch.setattr(file_dialogs_api, "select_file_dialog_backend", lambda: self) + + def answer( + self, + kind: DialogKind, + path: Optional[Path], + ) -> None: + """Queues the answer the next dialog of ``kind`` gives: ``path``, or ``None`` to dismiss it.""" + with self._lock: + self._answers[kind].append(path) + + @property + def requests(self) -> Tuple[DialogRequest, ...]: + with self._lock: + return tuple(self._requests) + + @property + def unanswered(self) -> Tuple[DialogRequest, ...]: + return tuple(request for request in self.requests if not request.answered) + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + del initial_directory, filters + return self._take(DialogKind.OPEN, title) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + del initial_directory, suggested_name, filters + return untyped_destination(self._take(DialogKind.SAVE, title)) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + del initial_directory + return self._take(DialogKind.DIRECTORY, title) + + def _take( + self, + kind: DialogKind, + title: str, + ) -> Optional[Path]: + with self._lock: + queued = self._answers[kind] + answered = bool(queued) + answer = queued.popleft() if answered else None + self._requests.append( + DialogRequest( + kind=kind, + title=title, + answered=answered, + answer=answer, + ) + ) + + return answer diff --git a/tests/suite/screens/boundaries/errors.py b/tests/suite/screens/boundaries/errors.py new file mode 100644 index 000000000..bcbeb9cbf --- /dev/null +++ b/tests/suite/screens/boundaries/errors.py @@ -0,0 +1,41 @@ +import logging +import threading +from typing import List, Tuple + +from sampletones_shared.application import SAMPLETONES_NAME + + +class ErrorRecords(logging.Handler): + """Every error the application logs, and every exception one of its threads lets escape. + + A gesture's callback that raises is logged and swallowed so the interface keeps running, which + is why a scenario reads this record: a click that failed this way leaves the screen looking calm. + """ + + def __init__(self) -> None: + super().__init__(level=logging.ERROR) + self._messages: List[str] = [] + self._lock = threading.Lock() + + def install(self) -> None: + logging.getLogger(SAMPLETONES_NAME).addHandler(self) + threading.excepthook = self._escaped + + @property + def messages(self) -> Tuple[str, ...]: + with self._lock: + return tuple(self._messages) + + def emit(self, record: logging.LogRecord) -> None: + if record.levelno < logging.ERROR: + return + + self._record(self.format(record)) + + def _escaped(self, arguments: threading.ExceptHookArgs) -> None: + thread = arguments.thread.name if arguments.thread is not None else "a thread" + self._record(f"{thread} let {arguments.exc_type.__name__} escape: {arguments.exc_value}") + + def _record(self, message: str) -> None: + with self._lock: + self._messages.append(message) diff --git a/tests/suite/screens/boundaries/spawns.py b/tests/suite/screens/boundaries/spawns.py new file mode 100644 index 000000000..823e0be90 --- /dev/null +++ b/tests/suite/screens/boundaries/spawns.py @@ -0,0 +1,77 @@ +import sys +import threading +from pathlib import Path +from typing import Any, Final, FrozenSet, List, Tuple + +LIBRARY_PROBES: Final[FrozenSet[str]] = frozenset({"cc", "gcc", "ld", "ldconfig", "objdump"}) +PROGRAM_ARGUMENT: Final[int] = 0 +ARGUMENTS_ARGUMENT: Final[int] = 1 +SPAWN_EVENTS: Final[FrozenSet[str]] = frozenset( + { + "os.exec", + "os.posix_spawn", + "os.spawn", + "os.startfile", + "os.system", + "subprocess.Popen", + "webbrowser.open", + } +) + + +class SpawnRefusedError(PermissionError): + """Raised inside the application when it tries to start a program during a scenario.""" + + +class SpawnGuard: + """Refuses every program the application tries to start once a scenario runs, and remembers each try. + + A file manager, a browser or a dialog tool would open on the desktop around the scenario's display. + The guard is an audit hook, so it stands in front of every way Python starts a program, however + a module imported it. The application's own worker processes start through multiprocessing, + which the hook leaves alone, and so are the tools ``ctypes.util.find_library`` asks where a + shared library lies. A refused try is remembered by its program and arguments alone, since the + environment it carried can hold the secrets of the machine the run is on. + """ + + def __init__(self) -> None: + self._refused: List[str] = [] + self._armed = False + self._lock = threading.Lock() + + def install(self) -> None: + """Puts the guard in front of the process for good; it refuses nothing until armed.""" + sys.addaudithook(self._audit) + + def arm(self) -> None: + self._armed = True + + @property + def refused(self) -> Tuple[str, ...]: + with self._lock: + return tuple(self._refused) + + def _audit( + self, + event: str, + arguments: Tuple[Any, ...], + ) -> None: + if not self._armed or event not in SPAWN_EVENTS: + return + + program = Path(str(arguments[PROGRAM_ARGUMENT])).name if arguments else "" + if program in LIBRARY_PROBES: + return + + attempt = f"{event} {program} {_described(arguments)}" + with self._lock: + self._refused.append(attempt) + + raise SpawnRefusedError(f"A screen scenario refuses to start a program: {attempt}") + + +def _described(arguments: Tuple[Any, ...]) -> str: + if len(arguments) > ARGUMENTS_ARGUMENT: + return repr(arguments[ARGUMENTS_ARGUMENT]) + + return "" diff --git a/tests/suite/screens/checks.py b/tests/suite/screens/checks.py new file mode 100644 index 000000000..3ec19e996 --- /dev/null +++ b/tests/suite/screens/checks.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import List, Tuple + +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_WINDOW_MAIN +from sampletones_application.utils.gui.modal_queue import ModalQueue, ModalQueueSnapshot +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import WindowReading, is_tag_within, read_client_area, read_windows + + +class AfterCheckError(AssertionError): + """Raised when a scenario's application broke a promise every scenario holds it to.""" + + +@dataclass(frozen=True) +class ScreenState: + """What the screen holds as a scenario ends: its windows, the modal line, and the client area.""" + + windows: Tuple[WindowReading, ...] + modal_line: ModalQueueSnapshot + client_area: Rect + + +def read_screen_state() -> ScreenState: + """Reads the screen as a scenario ends. Runs on the render thread.""" + return ScreenState( + windows=read_windows(), + modal_line=ModalQueue.snapshot(), + client_area=read_client_area(), + ) + + +def quiet_findings(errors: ErrorRecords) -> List[str]: + """The errors the application logged or let escape a thread.""" + return [f"The application reported an error: {message}" for message in errors.messages] + + +def contained_findings( + spawns: SpawnGuard, + dialogs: ScriptedFileDialogs, +) -> List[str]: + """The programs the application tried to start, and the dialogs it opened with no answer queued.""" + return [f"The application tried to start a program: {attempt}" for attempt in spawns.refused] + [ + f"The {request.kind} file dialog '{request.title}' opened with no answer queued" + for request in dialogs.unanswered + ] + + +def screen_findings(state: ScreenState) -> List[str]: + """What the screen still shows as a scenario ends: open conversations, error dialogs, windows off it.""" + findings: List[str] = [] + if state.modal_line.shown is not None or not state.modal_line.is_settled: + findings.append(f"The scenario ended with a modal conversation still open: {state.modal_line}") + + for window in state.windows: + if not window.shown or window.alias == TAG_GLOBAL_WINDOW_MAIN: + continue + if is_tag_within(window.alias, TAG_GLOBAL_DIALOG_ERROR): + findings.append(f"An error dialog stands on the screen: '{window.label}'") + if not state.client_area.contains(window.rect): + findings.append(f"The window '{window.alias}' reaches outside the viewport: {window.rect}") + + return findings diff --git a/tests/suite/screens/dearpygui/__init__.py b/tests/suite/screens/dearpygui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/dearpygui/bridge.py b/tests/suite/screens/dearpygui/bridge.py new file mode 100644 index 000000000..1a77e6698 --- /dev/null +++ b/tests/suite/screens/dearpygui/bridge.py @@ -0,0 +1,132 @@ +import threading +import time +from concurrent.futures import Future +from typing import Callable, Final, Protocol, TypeVar + +AnswerT = TypeVar("AnswerT") + +NEXT_DRAIN: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +POLL_SECONDS: Final[float] = 0.05 + + +class RenderThreadTimeoutError(AssertionError): + """Raised when the render thread leaves a question unanswered while it still runs.""" + + +class RenderThreadStoppedError(AssertionError): + """Raised when the application stops drawing before it answers a scenario's question.""" + + +class ExpectationError(AssertionError): + """Raised when what a scenario expects never holds within its time.""" + + +class RenderThread(Protocol): + """The thread an application's DearPyGui context belongs to, reached from a scenario's own thread. + + The application under test says how work reaches it, which is the one thing this layer asks of + the application. + """ + + def post( + self, + task: Callable[[], None], + *, + frames: int, + ) -> None: + """Runs ``task`` on the render thread once ``frames`` more frames have been drawn.""" + + def is_running(self) -> bool: + """Whether the render thread still draws frames, and so still runs what is posted to it.""" + + +class Bridge: + """The crossing a scenario takes to the render thread, where every reading and gesture happens. + + A question runs between two frames, so what it reads is one consistent frame. Its answer, or the + exception it raised, comes back to the scenario's thread. Waiting is counted in frames drawn, and + an expectation is read once a frame until it holds or its time runs out. + """ + + def __init__( + self, + render_thread: RenderThread, + *, + answer_timeout: float, + ) -> None: + self._render_thread = render_thread + self._answer_timeout = answer_timeout + + def ask(self, question: Callable[[], AnswerT]) -> AnswerT: + """Runs ``question`` on the render thread and returns what it answered. + + Raises: + RenderThreadTimeoutError: If the render thread leaves the question unanswered. + RenderThreadStoppedError: If the application stops before answering. + """ + answer: Future[AnswerT] = Future() + answered = threading.Event() + + def task() -> None: + try: + answer.set_result(question()) + except BaseException as error: # pylint: disable=broad-exception-caught + answer.set_exception(error) + finally: + answered.set() + + self._render_thread.post(task, frames=NEXT_DRAIN) + self._wait(answered, what="a question") + return answer.result() + + def frames(self, count: int) -> None: + """Waits until ``count`` more frames have been drawn. + + Raises: + RenderThreadTimeoutError: If the frames stop arriving. + RenderThreadStoppedError: If the application stops before they are drawn. + """ + drawn = threading.Event() + self._render_thread.post(drawn.set, frames=count) + self._wait(drawn, what=f"{count} frames") + + def expect( + self, + reading: Callable[[], AnswerT], + holds: Callable[[AnswerT], bool], + *, + description: str, + timeout: float, + ) -> AnswerT: + """Takes ``reading`` once a frame until ``holds`` accepts it, and returns that reading. + + ``reading`` runs on the caller's thread, so it asks the render thread for what it reads and + may combine several answers. + + Raises: + ExpectationError: If no reading within ``timeout`` seconds holds, naming the last one. + """ + deadline = time.monotonic() + timeout + while True: + value = reading() + if holds(value): + return value + + if time.monotonic() >= deadline: + raise ExpectationError(f"Expected {description} within {timeout} s; the last reading was {value!r}") + + self.frames(ONE_FRAME) + + def _wait( + self, + event: threading.Event, + *, + what: str, + ) -> None: + deadline = time.monotonic() + self._answer_timeout + while not event.wait(POLL_SECONDS): + if not self._render_thread.is_running() and not event.is_set(): + raise RenderThreadStoppedError(f"The application stopped before {what} came back") + if time.monotonic() >= deadline: + raise RenderThreadTimeoutError(f"The render thread left {what} unanswered for {self._answer_timeout} s") diff --git a/tests/suite/screens/dearpygui/display.py b/tests/suite/screens/dearpygui/display.py new file mode 100644 index 000000000..9afa02f45 --- /dev/null +++ b/tests/suite/screens/dearpygui/display.py @@ -0,0 +1,68 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, Final + +from pyvirtualdisplay.display import Display + + +class DisplayBackend(StrEnum): + XVFB = "xvfb" + XEPHYR = "xephyr" + + +SERVER_PROGRAMS: Final[Dict[DisplayBackend, str]] = { + DisplayBackend.XVFB: "Xvfb", + DisplayBackend.XEPHYR: "Xephyr", +} + + +class DisplayServerMissingError(RuntimeError): + """Raised when the X server a run draws on is not installed.""" + + +@dataclass(frozen=True) +class ScreenSize: + """The pixels a display's screen spans.""" + + width: int + height: int + + +class VirtualDisplay: + """An X server of a worker's own, which its scenarios draw on one after another. + + Xvfb draws into memory, which suits a run nobody watches. Xephyr draws into a window on the + desktop, which lets a person follow a scenario as it plays. Either server takes every event a + scenario sends, so the desktop around it stays untouched. + """ + + def __init__( + self, + backend: DisplayBackend, + size: ScreenSize, + ) -> None: + self._program = SERVER_PROGRAMS[backend] + self._server = Display( + backend=backend.value, + size=(size.width, size.height), + manage_global_env=False, + ) + + def start(self) -> str: + """Starts the server and returns the name a client connects to it under, such as ``:3``. + + Raises: + DisplayServerMissingError: If the server's program is not installed. + """ + if shutil.which(self._program) is None: + raise DisplayServerMissingError( + f"{self._program} is not installed. Install it from the system's packages, such as " + f"the xvfb package on Debian and Ubuntu, to run the screen scenarios." + ) + + self._server.start() + return self._server.new_display_var + + def stop(self) -> None: + self._server.stop() diff --git a/tests/suite/screens/dearpygui/geometry.py b/tests/suite/screens/dearpygui/geometry.py new file mode 100644 index 000000000..7907435fb --- /dev/null +++ b/tests/suite/screens/dearpygui/geometry.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + + +@dataclass(frozen=True) +class Point: + """A position on the screen, in whole pixels from its top left corner.""" + + x: int + y: int + + +@dataclass(frozen=True) +class Rect: + """A box on the screen: its top left corner and its size, in pixels.""" + + x: float + y: float + width: float + height: float + + @classmethod + def of( + cls, + corner: Sequence[float], + size: Sequence[float], + ) -> Rect: + """The box standing at ``corner`` with ``size``, as DearPyGui reports both.""" + return cls( + x=corner[0], + y=corner[1], + width=size[0], + height=size[1], + ) + + @property + def center(self) -> Point: + """The pixel a pointer aims at to land on the box.""" + return Point( + x=round(self.x + self.width / 2), + y=round(self.y + self.height / 2), + ) + + def contains(self, other: Rect) -> bool: + """Whether ``other`` lies within this box, edges included.""" + return ( + self.x <= other.x + and self.y <= other.y + and other.x + other.width <= self.x + self.width + and other.y + other.height <= self.y + self.height + ) diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py new file mode 100644 index 000000000..520ab4fec --- /dev/null +++ b/tests/suite/screens/dearpygui/hand.py @@ -0,0 +1,166 @@ +import time +from typing import Final, Optional, Sequence + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge, RenderThreadStoppedError +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.items import Item, read_viewport +from tests.suite.screens.dearpygui.keys import ( + IMGUI_LEFT_CTRL, + IMGUI_LEFT_SHIFT, + IMGUI_LETTER_A, + keysym_of, + keysym_of_character, +) +from tests.suite.screens.dearpygui.reach import UnreachableError, reachable +from tests.suite.screens.dearpygui.xtest import MouseButton, XTestDevice + +HOVER_FRAMES: Final[int] = 2 +HOLD_FRAMES: Final[int] = 2 +RELEASE_FRAMES: Final[int] = 1 +SETTLE_FRAMES: Final[int] = 2 +SINGLE_PRESS: Final[int] = 1 +DOUBLE_PRESS: Final[int] = 2 +REACH_TIMEOUT_SECONDS: Final[float] = 10.0 + + +class Hand: + """A user's hand on the application: the pointer and the keyboard of the scenario's display. + + A gesture first reads where its control stands, refusing one beyond a user's reach, and then + plays the events a person makes. Every step waits whole frames, since the application reads + input once a frame: the pointer rests over a control before pressing it, and a modifier is held + a frame before the key it modifies goes down. A gesture that closes the application ends where + the application stopped, which is how a scenario presses the button that leaves it. + """ + + def __init__( + self, + bridge: Bridge, + device: XTestDevice, + ) -> None: + self._bridge = bridge + self._device = device + + def click(self, item: Item) -> None: + """Presses ``item`` with the left button.""" + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + + def double_click(self, item: Item) -> None: + """Presses ``item`` twice in quick succession with the left button.""" + self._press(item, MouseButton.LEFT, DOUBLE_PRESS) + + def right_click(self, item: Item) -> None: + """Presses ``item`` with the right button, the gesture that opens a context menu.""" + self._press(item, MouseButton.RIGHT, SINGLE_PRESS) + + def hover(self, item: Item) -> None: + """Rests the pointer over ``item``.""" + self._device.move(self._aim(item)) + self._bridge.frames(HOVER_FRAMES) + + def press_key( + self, + key: int, + *, + modifiers: Sequence[int], + ) -> None: + """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" + held = tuple(keysym_of(modifier) for modifier in modifiers) + for keysym in held: + self._device.key_down(keysym) + if held: + self._settle(HOLD_FRAMES) + + self._tap(keysym_of(key)) + for keysym in reversed(held): + self._device.key_up(keysym) + + self._settle(SETTLE_FRAMES) + + def replace_text( + self, + item: Item, + text: str, + ) -> None: + """Clicks the field ``item``, selects what it holds, and types ``text`` over it.""" + self.click(item) + self.press_key(IMGUI_LETTER_A, modifiers=[IMGUI_LEFT_CTRL]) + self.type_text(text) + + def type_text(self, text: str) -> None: + """Types ``text`` into whatever holds the keyboard, one character a frame.""" + shift = keysym_of(IMGUI_LEFT_SHIFT) + for character in text: + keysym = keysym_of_character(character) + shifted = self._device.needs_shift(keysym) + if shifted: + self._device.key_down(shift) + + self._tap(keysym) + if shifted: + self._device.key_up(shift) + + self._settle(SETTLE_FRAMES) + + def _press( + self, + item: Item, + button: MouseButton, + count: int, + ) -> None: + self.hover(item) + for _ in range(count): + self._device.button_down(button) + self._settle(HOLD_FRAMES) + self._device.button_up(button) + self._settle(RELEASE_FRAMES) + + self._settle(SETTLE_FRAMES) + + def _tap(self, keysym: int) -> None: + self._device.key_down(keysym) + self._settle(HOLD_FRAMES) + self._device.key_up(keysym) + self._settle(RELEASE_FRAMES) + + def _settle(self, count: int) -> None: + """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" + try: + self._bridge.frames(count) + except RenderThreadStoppedError: + return + + def _aim(self, item: Item) -> Point: + """The screen pixel at the middle of ``item``, once a user could press it and it holds still. + + A control takes frames to appear after the gesture that brings it, and a window sized by its + content settles its place over its first frames. The hand waits until the control is in reach + and stands where it stood a frame before, the way a person waits for a dialog to stop moving, + and reports why it stayed out of reach once the wait runs out. + """ + + def read() -> Point: + center = reachable(item).center + viewport = read_viewport() + return Point( + x=round(viewport.x) + center.x, + y=round(viewport.y) + center.y, + ) + + deadline = time.monotonic() + REACH_TIMEOUT_SECONDS + previous: Optional[Point] = None + while True: + try: + point = self._bridge.ask(read) + except UnreachableError: + if time.monotonic() >= deadline: + raise + point = None + + if point is not None and point == previous: + return point + if time.monotonic() >= deadline: + raise UnreachableError(f"{item!r} kept moving: it stood at {previous} and then at {point}") + + previous = point + self._bridge.frames(ONE_FRAME) diff --git a/tests/suite/screens/dearpygui/hosting.py b/tests/suite/screens/dearpygui/hosting.py new file mode 100644 index 000000000..200ea39c6 --- /dev/null +++ b/tests/suite/screens/dearpygui/hosting.py @@ -0,0 +1,89 @@ +import threading +from typing import Callable, Final, List, Protocol + +SCENARIO_THREAD_NAME: Final[str] = "scenario" + + +class ScenarioHangError(AssertionError): + """Raised when a scenario's thread is still running after the application stopped.""" + + +class HostedLoop(Protocol): + """An application's render loop, run on the main thread while a scenario drives it from another. + + The host runs the loop where a windowing system wants its events handled. The scenario finishes + the run from its own thread, and the host closes it once the loop has returned. + """ + + def run(self) -> None: + """Draws frames on this thread until the application stops.""" + + def finish(self, *, failed: bool) -> None: + """Brings the application to a stop once the scenario is done. Runs on the scenario's thread. + + Raises: + AssertionError: Naming what the application showed wrong while it stopped. + """ + + def close(self) -> None: + """Reads what the stopped application left behind. Runs on the main thread after the loop. + + Raises: + AssertionError: Naming what the application left wrong. + """ + + +def host( + scenario: Callable[[], None], + loop: HostedLoop, + *, + join_timeout: float, +) -> None: + """Runs ``scenario`` on a thread of its own while this thread runs ``loop``, then reports how it went. + + The first failure is the one raised, and every later one rides along as a note on it, so a + failing scenario still reports what its application did wrong on the way out. + + Raises: + BaseException: The first failure met: the scenario's own, the finish's or the close's. + ScenarioHangError: If the scenario's thread outlives the loop by more than ``join_timeout``. + """ + failures: List[BaseException] = [] + + def drive() -> None: + try: + scenario() + except BaseException as error: # pylint: disable=broad-exception-caught + failures.append(error) + + try: + loop.finish(failed=bool(failures)) + except BaseException as error: # pylint: disable=broad-exception-caught + failures.append(error) + + thread = threading.Thread(target=drive, name=SCENARIO_THREAD_NAME, daemon=True) + thread.start() + loop.run() + thread.join(join_timeout) + if thread.is_alive(): + failures.append( + ScenarioHangError(f"The scenario was still running {join_timeout} s after the application stopped") + ) + + try: + loop.close() + except AssertionError as error: + failures.append(error) + + _raise_first(failures) + + +def _raise_first(failures: List[BaseException]) -> None: + if not failures: + return + + first, *rest = failures + for later in rest: + first.add_note(f"Also: {type(later).__name__}: {later}") + + raise first diff --git a/tests/suite/screens/dearpygui/isolation.py b/tests/suite/screens/dearpygui/isolation.py new file mode 100644 index 000000000..7794da4fd --- /dev/null +++ b/tests/suite/screens/dearpygui/isolation.py @@ -0,0 +1,184 @@ +import json +import signal +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Literal, Mapping, Optional, Sequence, Union + +import pytest + +PHASES: Final[Sequence[Literal["setup", "call", "teardown"]]] = ("setup", "call", "teardown") +CHILD_ARGUMENTS: Final[Sequence[str]] = ( + "-p", + "no:cacheprovider", + "-p", + "no:cov", + "-p", + "no:xdist", + "-q", + "--no-header", +) +OUTPUT_TAIL_CHARACTERS: Final[int] = 20_000 + + +@dataclass(frozen=True) +class ChildRun: + """How a child process ended: its exit status, or ``None`` when it ran out of time, and its output.""" + + returncode: Optional[int] + output: str + + @property + def crashed(self) -> bool: + """Whether the process ended by a signal or ran out of time.""" + return self.returncode is None or self.returncode < 0 + + @property + def ending(self) -> str: + """The way the process ended, in words a failure report can carry.""" + if self.returncode is None: + return "ran out of time and was stopped" + if self.returncode < 0: + return f"was killed by {signal.Signals(-self.returncode).name}" + + return f"exited with status {self.returncode}" + + +class ReportRecorder: + """Writes each report the child's run makes to a file, in the form pytest sends between processes. + + A report is written the moment its phase ends, so a process that dies in a later phase still + leaves the earlier reports behind. + """ + + def __init__( + self, + config: pytest.Config, + path: Path, + ) -> None: + self._config = config + self._path = path + + @pytest.hookimpl + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + data = self._config.hook.pytest_report_to_serializable(config=self._config, report=report) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(data) + "\n") + + +def run_isolated( + item: pytest.Item, + *, + environment: Mapping[str, str], + report_path: Path, + timeout: float, +) -> List[pytest.TestReport]: + """Runs ``item`` in a fresh interpreter and returns its reports, as if it had run here. + + The child is a pytest run of the item's node id alone, under ``environment``. It writes its + reports to ``report_path``. A phase missing from them, because the child crashed or ran out of + time, comes back as a failure carrying the child's output, and so does the last phase of a child + that crashed after reporting all of them. + """ + child = _run_child( + item, + environment=environment, + timeout=timeout, + ) + reports = _read_reports(item.config, report_path) + return _completed(item, reports, child) + + +def _run_child( + item: pytest.Item, + *, + environment: Mapping[str, str], + timeout: float, +) -> ChildRun: + command = [sys.executable, "-m", "pytest", item.nodeid, *CHILD_ARGUMENTS] + try: + completed = subprocess.run( + command, + cwd=item.config.rootpath, + env=dict(environment), + timeout=timeout, + capture_output=True, + text=True, + check=False, + ) + except subprocess.TimeoutExpired as expired: + return ChildRun( + returncode=None, + output=_text(expired.stdout) + _text(expired.stderr), + ) + + return ChildRun( + returncode=completed.returncode, + output=completed.stdout + completed.stderr, + ) + + +def _read_reports(config: pytest.Config, path: Path) -> Dict[str, pytest.TestReport]: + if not path.exists(): + return {} + + reports: Dict[str, pytest.TestReport] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + report = config.hook.pytest_report_from_serializable(config=config, data=json.loads(line)) + reports[report.when] = report + + return reports + + +def _completed( + item: pytest.Item, + reports: Dict[str, pytest.TestReport], + child: ChildRun, +) -> List[pytest.TestReport]: + completed: List[pytest.TestReport] = [] + for phase in PHASES: + report = reports.get(phase) + if report is None: + report = _missing_report(item, phase, child, failed=not completed or completed[-1].passed) + + completed.append(report) + if report.failed and phase == "setup": + break + + if child.crashed and len(reports) == len(PHASES): + completed[-1] = _missing_report(item, PHASES[-1], child, failed=True) + + return completed + + +def _missing_report( + item: pytest.Item, + phase: Literal["setup", "call", "teardown"], + child: ChildRun, + *, + failed: bool, +) -> pytest.TestReport: + longrepr = None + if failed: + longrepr = ( + f"The scenario's process {child.ending} around its {phase}.\n\n{child.output[-OUTPUT_TAIL_CHARACTERS:]}" + ) + + return pytest.TestReport( + nodeid=item.nodeid, + location=item.location, + keywords={keyword: 1 for keyword in item.keywords}, + outcome="failed" if failed else "passed", + longrepr=longrepr, + when=phase, + ) + + +def _text(output: Optional[Union[bytes, str]]) -> str: + if output is None: + return "" + if isinstance(output, bytes): + return output.decode(errors="replace") + + return output diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py new file mode 100644 index 000000000..d8ff56552 --- /dev/null +++ b/tests/suite/screens/dearpygui/items.py @@ -0,0 +1,193 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final, List, Optional, Tuple, Union + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect + +Item = Union[int, str] + +WINDOW_TYPE: Final[str] = "mvAppItemType::mvWindowAppItem" +MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" +MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" +TAG_SEPARATOR: Final[str] = "." + + +@dataclass(frozen=True) +class ItemReading: + """What one item showed in the last frame: whether it stands, where, and whether it answers. + + Attributes: + item: The item read. + exists: Whether the item is in the context at all. + shown: Whether the item and every container around it are shown. + visible: Whether the item was drawn inside its clip region in the last frame. + enabled: Whether the item answers a press. + rect: The item's box on the screen, where its kind reports one. + """ + + item: Item + exists: bool + shown: bool + visible: bool + enabled: bool + rect: Optional[Rect] + + +@dataclass(frozen=True) +class WindowReading: + """One top-level window as the last frame drew it. + + Attributes: + alias: The window's tag. + label: The title the window carries. + modal: Whether the window holds the screen while it stands. + shown: Whether the window is shown. + rect: The window's box on the screen. + """ + + alias: str + label: str + modal: bool + shown: bool + rect: Rect + + +def read_item(item: Item) -> ItemReading: + """Reads ``item`` as the last frame drew it. Runs on the render thread.""" + if not dpg.does_item_exist(item): + return ItemReading( + item=item, + exists=False, + shown=False, + visible=False, + enabled=False, + rect=None, + ) + + state = dpg.get_item_state(item) + configuration = dpg.get_item_configuration(item) + return ItemReading( + item=item, + exists=True, + shown=_shown_with_ancestors(item), + visible=bool(state.get("visible", True)), + enabled=bool(configuration.get("enabled", True)), + rect=_rect(item, state), + ) + + +def read_windows() -> Tuple[WindowReading, ...]: + """Reads every top-level window the context holds. Runs on the render thread.""" + windows = [] + for window in dpg.get_windows(): + if dpg.get_item_info(window)["type"] != WINDOW_TYPE: + continue + + configuration = dpg.get_item_configuration(window) + windows.append( + WindowReading( + alias=str(dpg.get_item_alias(window)), + label=str(configuration.get("label") or ""), + modal=bool(configuration.get("modal")), + shown=bool(dpg.is_item_shown(window)), + rect=_window_rect(window), + ) + ) + + return tuple(windows) + + +def read_label(item: Item) -> str: + """The label ``item`` carries. Runs on the render thread.""" + return str(dpg.get_item_label(item) or "") + + +def read_value(item: Item) -> Any: + """The value ``item`` holds, whatever kind the item keeps. Runs on the render thread.""" + return dpg.get_value(item) + + +def find_labelled( + container: Item, + label: str, + *, + item_type: str, +) -> Item: + """The first item of ``item_type`` under ``container`` whose label reads ``label``. Runs on the render thread. + + A label may carry padding around its words, so the words are compared with the padding stripped. + + Raises: + LookupError: If no such item stands under ``container``. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + info = dpg.get_item_info(item) + if info["type"] == item_type and read_label(item).strip() == label.strip(): + return item + + for children in info["children"].values(): + pending.extend(children) + + raise LookupError(f"No {item_type} labelled '{label}' stands under {container!r}") + + +def read_selected_tab(tab_bar: str) -> str: + """The tag of the tab standing in front of ``tab_bar``. Runs on the render thread.""" + return str(dpg.get_item_alias(dpg.get_value(tab_bar))) + + +def read_viewport() -> Rect: + """The viewport's client area, placed where it stands on the screen. Runs on the render thread.""" + corner = dpg.get_viewport_pos() + return Rect( + x=corner[0], + y=corner[1], + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) + + +def read_client_area() -> Rect: + """The viewport's client area in the coordinates its items report. Runs on the render thread.""" + return Rect( + x=0, + y=0, + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) + + +def is_tag_within(alias: str, prefix: str) -> bool: + """Whether ``alias`` is ``prefix`` itself or a tag composed under it.""" + return alias == prefix or alias.startswith(prefix + TAG_SEPARATOR) + + +def _shown_with_ancestors(tag: Item) -> bool: + item: Optional[Item] = tag + while item is not None: + if not dpg.is_item_shown(item): + return False + + item = dpg.get_item_parent(item) + + return True + + +def _rect(item: Item, state: Dict[str, Any]) -> Optional[Rect]: + if "rect_min" in state: + return Rect.of(state["rect_min"], state["rect_size"]) + + if dpg.get_item_info(item)["type"] == WINDOW_TYPE: + return _window_rect(item) + + return None + + +def _window_rect(window: Item) -> Rect: + return Rect.of( + dpg.get_item_pos(window), + dpg.get_item_state(window)["rect_size"], + ) diff --git a/tests/suite/screens/dearpygui/keys.py b/tests/suite/screens/dearpygui/keys.py new file mode 100644 index 000000000..24ef16d07 --- /dev/null +++ b/tests/suite/screens/dearpygui/keys.py @@ -0,0 +1,87 @@ +from typing import Dict, Final + +from Xlib import XK + +IMGUI_TAB: Final[int] = 512 +IMGUI_LEFT: Final[int] = 513 +IMGUI_RIGHT: Final[int] = 514 +IMGUI_UP: Final[int] = 515 +IMGUI_DOWN: Final[int] = 516 +IMGUI_PAGE_UP: Final[int] = 517 +IMGUI_PAGE_DOWN: Final[int] = 518 +IMGUI_HOME: Final[int] = 519 +IMGUI_END: Final[int] = 520 +IMGUI_INSERT: Final[int] = 521 +IMGUI_DELETE: Final[int] = 522 +IMGUI_BACKSPACE: Final[int] = 523 +IMGUI_SPACE: Final[int] = 524 +IMGUI_ENTER: Final[int] = 525 +IMGUI_ESCAPE: Final[int] = 526 +IMGUI_LEFT_CTRL: Final[int] = 527 +IMGUI_LEFT_SHIFT: Final[int] = 528 +IMGUI_LEFT_ALT: Final[int] = 529 +IMGUI_DIGIT_ZERO: Final[int] = 536 +IMGUI_LETTER_A: Final[int] = 546 +IMGUI_F1: Final[int] = 572 + +DIGIT_COUNT: Final[int] = 10 +LETTER_COUNT: Final[int] = 26 +FUNCTION_KEY_COUNT: Final[int] = 12 + +NAMED_KEYSYMS: Final[Dict[int, str]] = { + IMGUI_TAB: "Tab", + IMGUI_LEFT: "Left", + IMGUI_RIGHT: "Right", + IMGUI_UP: "Up", + IMGUI_DOWN: "Down", + IMGUI_PAGE_UP: "Prior", + IMGUI_PAGE_DOWN: "Next", + IMGUI_HOME: "Home", + IMGUI_END: "End", + IMGUI_INSERT: "Insert", + IMGUI_DELETE: "Delete", + IMGUI_BACKSPACE: "BackSpace", + IMGUI_SPACE: "space", + IMGUI_ENTER: "Return", + IMGUI_ESCAPE: "Escape", + IMGUI_LEFT_CTRL: "Control_L", + IMGUI_LEFT_SHIFT: "Shift_L", + IMGUI_LEFT_ALT: "Alt_L", +} +DIGIT_KEYSYMS: Final[Dict[int, str]] = {IMGUI_DIGIT_ZERO + offset: str(offset) for offset in range(DIGIT_COUNT)} +LETTER_KEYSYMS: Final[Dict[int, str]] = { + IMGUI_LETTER_A + offset: chr(ord("a") + offset) for offset in range(LETTER_COUNT) +} +FUNCTION_KEYSYMS: Final[Dict[int, str]] = {IMGUI_F1 + offset: f"F{offset + 1}" for offset in range(FUNCTION_KEY_COUNT)} +KEYSYM_NAMES: Final[Dict[int, str]] = { + **NAMED_KEYSYMS, + **DIGIT_KEYSYMS, + **LETTER_KEYSYMS, + **FUNCTION_KEYSYMS, +} + + +class UnmappedKeyError(LookupError): + """Raised when a scenario presses a key the X keyboard table holds no name for.""" + + +def keysym_of(key: int) -> int: + """The X keysym a press of the Dear ImGui key ``key`` arrives as. + + Dear ImGui numbers its keys in an enumeration of its own, and DearPyGui reports a press under + that number. A few of DearPyGui's own ``mvKey_*`` constants carry older values, which is why the + table names the enumeration's numbers. + + Raises: + UnmappedKeyError: If the table holds no name for ``key``. + """ + name = KEYSYM_NAMES.get(key) + if name is None: + raise UnmappedKeyError(f"The X keyboard table holds no name for the Dear ImGui key {key}") + + return XK.string_to_keysym(name) + + +def keysym_of_character(character: str) -> int: + """The X keysym typing ``character`` sends; a printable ASCII keysym is its own code point.""" + return ord(character) diff --git a/tests/suite/screens/dearpygui/reach.py b/tests/suite/screens/dearpygui/reach.py new file mode 100644 index 000000000..07a7c8969 --- /dev/null +++ b/tests/suite/screens/dearpygui/reach.py @@ -0,0 +1,70 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import Item, ItemReading, read_client_area, read_item, read_windows + + +class UnreachableError(AssertionError): + """Raised when a scenario reaches for a control a user could not press.""" + + +def reachable(item: Item) -> Rect: + """The box of ``item``, once it stands where a user could press it. Runs on the render thread. + + A control is in reach when it exists, it and every container around it are shown, it was drawn + in the last frame, it answers a press, its box lies inside the viewport, and no modal window of + another tree holds the screen. + + Raises: + UnreachableError: Naming the first condition the control misses. + """ + reading = read_item(item) + refusal = _refusal(reading) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + assert reading.rect is not None + return reading.rect + + +def _refusal(reading: ItemReading) -> Optional[str]: + if not reading.exists: + return "it does not exist" + if not reading.shown: + return "it or a container around it is hidden" + if not reading.visible: + return "it was not drawn in the last frame" + if not reading.enabled: + return "it is disabled" + if reading.rect is None: + return "its kind reports no box to press" + if not read_client_area().contains(reading.rect): + return f"its box {reading.rect} reaches outside the viewport" + + covering = covering_modal(reading.item) + if covering is not None: + return f"the modal window '{covering}' holds the screen" + + return None + + +def covering_modal(item: Item) -> Optional[str]: + """The modal window of another tree holding the screen over ``item``, if one does.""" + root = _root_window(item) + for window in read_windows(): + if window.shown and window.modal and window.alias != root: + return window.alias + + return None + + +def _root_window(item: Item) -> str: + root: Item = item + parent = dpg.get_item_parent(root) + while parent is not None: + root = parent + parent = dpg.get_item_parent(root) + + return str(dpg.get_item_alias(root)) diff --git a/tests/suite/screens/dearpygui/screenshot.py b/tests/suite/screens/dearpygui/screenshot.py new file mode 100644 index 000000000..a431d3fbc --- /dev/null +++ b/tests/suite/screens/dearpygui/screenshot.py @@ -0,0 +1,24 @@ +from pathlib import Path +from typing import Final + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import Bridge + +WRITE_TIMEOUT_SECONDS: Final[float] = 10.0 + + +def capture(bridge: Bridge, path: Path) -> Path: + """Saves the next frame the application draws as a PNG at ``path``, and returns the path. + + DearPyGui writes the picture while drawing a frame, so the capture waits for the frame to land. + """ + path.parent.mkdir(parents=True, exist_ok=True) + bridge.ask(lambda: dpg.output_frame_buffer(file=str(path))) + bridge.expect( + lambda: bridge.ask(path.exists), + bool, + description=f"the screenshot {path} to be written", + timeout=WRITE_TIMEOUT_SECONDS, + ) + return path diff --git a/tests/suite/screens/dearpygui/semantic.py b/tests/suite/screens/dearpygui/semantic.py new file mode 100644 index 000000000..0377e9d7d --- /dev/null +++ b/tests/suite/screens/dearpygui/semantic.py @@ -0,0 +1,60 @@ +from inspect import Parameter, signature +from typing import Any, Callable, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.items import Item, ItemReading, read_item +from tests.suite.screens.dearpygui.reach import UnreachableError, covering_modal + +CallbackRunner = Callable[..., object] + +POSITIONAL: Final = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + + +def invoke( + item: Item, + run: CallbackRunner, +) -> None: + """Runs ``item``'s callback as DearPyGui runs it for a press, through ``run``. Runs on the render thread. + + This is the gesture for a control DearPyGui reports a position alone for, such as a menu entry. The + control must exist, stand shown, answer a press and sit outside any other tree's modal window. + The callback receives as many of the sender, the value and the user data as it declares, which + is what DearPyGui hands it. + + Raises: + UnreachableError: Naming what keeps a user from the control. + """ + refusal = _refusal(read_item(item)) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + callback = dpg.get_item_callback(item) + if callback is None: + raise UnreachableError(f"{item!r} answers no press: it carries no callback") + + arguments = (item, dpg.get_value(item), dpg.get_item_user_data(item)) + run(callback, *_declared(callback, arguments)) + + +def _refusal(reading: ItemReading) -> Optional[str]: + if not reading.exists: + return "it does not exist" + if not reading.shown: + return "it or a container around it is hidden" + if not reading.enabled: + return "it is disabled" + + covering = covering_modal(reading.item) + if covering is not None: + return f"the modal window '{covering}' holds the screen" + + return None + + +def _declared(callback: Callable[..., object], arguments: Sequence[Any]) -> Sequence[Any]: + parameters = signature(callback).parameters.values() + if any(parameter.kind is Parameter.VAR_POSITIONAL for parameter in parameters): + return arguments + + return arguments[: sum(1 for parameter in parameters if parameter.kind in POSITIONAL)] diff --git a/tests/suite/screens/dearpygui/xtest.py b/tests/suite/screens/dearpygui/xtest.py new file mode 100644 index 000000000..61ac6d9c5 --- /dev/null +++ b/tests/suite/screens/dearpygui/xtest.py @@ -0,0 +1,68 @@ +from enum import IntEnum +from typing import Final + +from Xlib import X +from Xlib.display import Display +from Xlib.ext import xtest + +from tests.suite.screens.dearpygui.geometry import Point + +SHIFTED_COLUMN: Final[int] = 1 + + +class MouseButton(IntEnum): + LEFT = 1 + MIDDLE = 2 + RIGHT = 3 + + +class UntypableCharacterError(LookupError): + """Raised when the display's keyboard map holds no key that types a character.""" + + +class XTestDevice: + """The pointer and the keyboard of one X display, driven through the XTEST extension. + + Each call sends one event and flushes it, so the server holds it before the next frame polls. + The display is the scenario's own, which keeps every event inside the application under test. + """ + + def __init__(self, display_name: str) -> None: + self._display = Display(display_name) + + def move(self, point: Point) -> None: + xtest.fake_input(self._display, X.MotionNotify, x=point.x, y=point.y) + self._display.sync() + + def button_down(self, button: MouseButton) -> None: + xtest.fake_input(self._display, X.ButtonPress, int(button)) + self._display.sync() + + def button_up(self, button: MouseButton) -> None: + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def key_down(self, keysym: int) -> None: + xtest.fake_input(self._display, X.KeyPress, self._keycode(keysym)) + self._display.sync() + + def key_up(self, keysym: int) -> None: + xtest.fake_input(self._display, X.KeyRelease, self._keycode(keysym)) + self._display.sync() + + def needs_shift(self, keysym: int) -> bool: + """Whether typing ``keysym`` takes Shift, which is where the keyboard map shifts it.""" + keycode = self._keycode(keysym) + return self._display.keycode_to_keysym(keycode, 0) != keysym and ( + self._display.keycode_to_keysym(keycode, SHIFTED_COLUMN) == keysym + ) + + def close(self) -> None: + self._display.close() + + def _keycode(self, keysym: int) -> int: + keycode = self._display.keysym_to_keycode(keysym) + if keycode == 0: + raise UntypableCharacterError(f"The keyboard map holds no key for the keysym {keysym:#x}") + + return int(keycode) diff --git a/tests/suite/screens/environment.py b/tests/suite/screens/environment.py new file mode 100644 index 000000000..b3524a208 --- /dev/null +++ b/tests/suite/screens/environment.py @@ -0,0 +1,89 @@ +import re +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Mapping, Tuple + +from sampletones_shared.application import SAMPLETONES_ENV_PREFIX +from tests.suite.screens.dearpygui.display import DisplayBackend, ScreenSize +from tests.suite.screens.paths import ARTIFACTS_DIRECTORY, HOME_FOLDER, NO_BUS_FILE, REPORTS_FILE + +DISPLAY_BACKEND_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_DISPLAY" +ARTIFACTS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_ARTIFACTS" +REPORT_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPORT" +STRICT_HISTORY_VARIABLE: Final[str] = f"{SAMPLETONES_ENV_PREFIX}STRICT_HISTORY" +STRICT_HISTORY: Final[str] = "true" +NO_INPUT_METHOD: Final[str] = "@im=none" +DROPPED_PREFIXES: Final[Tuple[str, ...]] = ("PYTEST_", "WAYLAND_") +UNSAFE_CHARACTERS: Final[re.Pattern[str]] = re.compile(r"[^A-Za-z0-9_.-]+") +SCREEN_SIZE: Final[ScreenSize] = ScreenSize(width=1600, height=1000) +DEFAULT_DISPLAY_BACKEND: Final[DisplayBackend] = DisplayBackend.XVFB + + +def display_backend(environment: Mapping[str, str]) -> DisplayBackend: + """The X server a run draws on: Xvfb unless ``SAMPLETONES_SCREENS_DISPLAY`` names another.""" + return DisplayBackend(environment.get(DISPLAY_BACKEND_VARIABLE, DEFAULT_DISPLAY_BACKEND)) + + +@dataclass(frozen=True) +class ScenarioFolders: + """Where one scenario's run keeps its files: the home its application lives in, and its reports. + + Attributes: + root: The scenario's folder under the run's artifacts, kept after the run for a reader. + home: The home directory the application reads and writes as the user's own. + reports: The file the child process writes its pytest reports to. + """ + + root: Path + home: Path + reports: Path + + @classmethod + def of(cls, nodeid: str) -> "ScenarioFolders": + """The folders of the scenario ``nodeid`` names, under a name a file system accepts.""" + root = ARTIFACTS_DIRECTORY / UNSAFE_CHARACTERS.sub("_", nodeid) + return cls( + root=root, + home=root / HOME_FOLDER, + reports=root / REPORTS_FILE, + ) + + def prepare(self) -> None: + """Clears what an earlier run of the scenario left, and lays out an empty home.""" + shutil.rmtree(self.root, ignore_errors=True) + self.home.mkdir(parents=True) + + +def child_environment( + base: Mapping[str, str], + folders: ScenarioFolders, + *, + display: str, +) -> Dict[str, str]: + """The environment a scenario's process starts under: ``base``, pointed at the scenario's own world. + + The home and every XDG directory lie inside the scenario's folder, so settings, session state + and the documents folder start empty and stay apart from the user's. The display is the + worker's own server, the session bus address leads nowhere, and the input method is off, so + everything a scenario does stays on its own display and in its own home. + """ + environment = {name: value for name, value in base.items() if not name.startswith(DROPPED_PREFIXES)} + home = folders.home + environment.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + "XDG_DOCUMENTS_DIR": str(home / "Documents"), + "DISPLAY": display, + "DBUS_SESSION_BUS_ADDRESS": f"unix:path={folders.root / NO_BUS_FILE}", + "XMODIFIERS": NO_INPUT_METHOD, + STRICT_HISTORY_VARIABLE: STRICT_HISTORY, + ARTIFACTS_VARIABLE: str(folders.root), + REPORT_VARIABLE: str(folders.reports), + } + ) + return environment diff --git a/tests/suite/screens/keyboard.py b/tests/suite/screens/keyboard.py new file mode 100644 index 000000000..c04e0f4cc --- /dev/null +++ b/tests/suite/screens/keyboard.py @@ -0,0 +1,23 @@ +from typing import Final + +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.modifiers import MODIFIER_KEYS, Modifier +from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut +from tests.suite.screens.dearpygui.hand import Hand + +LEFT_KEY: Final[int] = 0 +PRIMARY_COMBINATION: Final[int] = 0 + + +def primary_combination(shortcut: Shortcut) -> KeyCombination: + """The combination an action displays, which is the one a person presses.""" + return shortcut.combinations()[PRIMARY_COMBINATION] + + +def press_combination(hand: Hand, combination: KeyCombination) -> None: + """Presses ``combination`` on the real keyboard: its modifiers on the left side, then its key. + + Modifiers go down in the order a combination displays them, which is the order a person holds them. + """ + modifiers = [MODIFIER_KEYS[modifier][LEFT_KEY] for modifier in Modifier if modifier in combination.modifiers] + hand.press_key(combination.key, modifiers=modifiers) diff --git a/tests/suite/screens/paths.py b/tests/suite/screens/paths.py new file mode 100644 index 000000000..fab9ab77e --- /dev/null +++ b/tests/suite/screens/paths.py @@ -0,0 +1,12 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.source import REPOSITORY_ROOT + +SCREENS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "screens" +DEARPYGUI_LAYER_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" / "dearpygui" +ARTIFACTS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "screens" +HOME_FOLDER: Final[str] = "home" +REPORTS_FILE: Final[str] = "reports.jsonl" +NO_BUS_FILE: Final[str] = "no-bus" +FAILURE_SCREENSHOT: Final[str] = "failure.png" diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py new file mode 100644 index 000000000..62ae8f386 --- /dev/null +++ b/tests/suite/screens/plugin.py @@ -0,0 +1,242 @@ +import os +from pathlib import Path +from typing import Final, Generator, Iterator, Optional + +import pytest + +from sampletones_application.application import Application +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_shared.utils.serialization import save_yaml_atomic +from tests.suite.screens.application import NOTHING_TO_OPEN, Boundaries, ScreenApplication, Startup +from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.display import VirtualDisplay +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.hosting import host +from tests.suite.screens.dearpygui.isolation import ReportRecorder, run_isolated +from tests.suite.screens.dearpygui.xtest import XTestDevice +from tests.suite.screens.environment import ( + ARTIFACTS_VARIABLE, + REPORT_VARIABLE, + SCREEN_SIZE, + ScenarioFolders, + child_environment, + display_backend, +) +from tests.suite.screens.paths import FAILURE_SCREENSHOT, SCREENS_DIRECTORY +from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.screen import Screen + +CHILD_TIMEOUT_SECONDS: Final[float] = 180.0 +ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 +JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 +REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" +SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" +DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() +DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() + + +class ScreenScenarioError(AssertionError): + """Raised when a screen scenario is written in a way the tier cannot host.""" + + +def is_screen_item(item: pytest.Item) -> bool: + """Whether ``item`` is a screen scenario, which is any test under ``tests/screens``.""" + return Path(item.path).is_relative_to(SCREENS_DIRECTORY) + + +def is_child_process() -> bool: + """Whether this pytest run is the fresh process one scenario runs in.""" + return REPORT_VARIABLE in os.environ + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_protocol( + item: pytest.Item, + nextitem: Optional[pytest.Item], +) -> Optional[bool]: + """Runs a screen scenario in a process of its own and reports what that process reported.""" + del nextitem + if is_child_process() or not is_screen_item(item): + return None + + folders = ScenarioFolders.of(item.nodeid) + folders.prepare() + environment = child_environment( + os.environ, + folders, + display=_worker_display(item.config), + ) + item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) + for report in run_isolated( + item, + environment=environment, + report_path=folders.reports, + timeout=CHILD_TIMEOUT_SECONDS, + ): + item.ihook.pytest_runtest_logreport(report=report) + + item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) + return True + + +def pytest_configure(config: pytest.Config) -> None: + if is_child_process(): + recorder = ReportRecorder(config, Path(os.environ[REPORT_VARIABLE])) + config.pluginmanager.register(recorder, REPORT_RECORDER_NAME) + + +def pytest_unconfigure(config: pytest.Config) -> None: + display = config.stash.get(DISPLAY_KEY, None) + if display is not None: + display.stop() + + +@pytest.hookimpl(wrapper=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None, Optional[object], Optional[object]]: + """Runs a screen scenario's body on a thread of its own while the application draws on this one.""" + if not is_child_process() or not is_screen_item(pyfuncitem): + return (yield) + + loop = pyfuncitem.funcargs.get(SCREEN_APPLICATION_FIXTURE) + if not isinstance(loop, ScreenApplication): + raise ScreenScenarioError(f"A screen scenario takes the screen fixture: {pyfuncitem.nodeid}") + + original = pyfuncitem.obj + + def hosted(*arguments: object, **keywords: object) -> None: + host( + lambda: original(*arguments, **keywords), + loop, + join_timeout=JOIN_TIMEOUT_SECONDS, + ) + + pyfuncitem.obj = hosted + try: + return (yield) + finally: + pyfuncitem.obj = original + + +@pytest.fixture +def startup() -> Startup: + """The documents the application opens as it starts; a scenario overrides it to open its own.""" + return NOTHING_TO_OPEN + + +@pytest.fixture +def output_device() -> OutputDevice: + """The output the scenario's machine offers; a scenario overrides it to start with none.""" + return OutputDevice.SILENT + + +@pytest.fixture +def screen_render_thread() -> QueueRenderThread: + return QueueRenderThread() + + +@pytest.fixture +def screen_bridge(screen_render_thread: QueueRenderThread) -> Bridge: + return Bridge(screen_render_thread, answer_timeout=ANSWER_TIMEOUT_SECONDS) + + +@pytest.fixture +def screen_boundaries( + output_device: OutputDevice, + monkeypatch: pytest.MonkeyPatch, +) -> Boundaries: + """Puts the scenario's stand-ins between the application and the desktop before it starts.""" + dialogs = ScriptedFileDialogs() + dialogs.install(monkeypatch) + errors = ErrorRecords() + errors.install() + spawns = SpawnGuard() + spawns.install() + provide_output_device( + output_device, + home=Path.home(), + monkeypatch=monkeypatch, + ) + return Boundaries( + dialogs=dialogs, + errors=errors, + spawns=spawns, + ) + + +@pytest.fixture +def screen_application( + startup: Startup, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> ScreenApplication: + """SampleToNES as ``sampletones open`` starts it, on the scenario's display and in its home.""" + profile = UserProfile.user() + _seed_viewport(profile.state) + application = Application( + profile=profile, + reconstruction_path=startup.reconstruction, + project_path=startup.project, + ) + return ScreenApplication( + application, + render_thread=screen_render_thread, + bridge=screen_bridge, + boundaries=screen_boundaries, + failure_screenshot=Path(os.environ[ARTIFACTS_VARIABLE]) / FAILURE_SCREENSHOT, + state_path=profile.state, + ) + + +@pytest.fixture +def screen( + screen_application: ScreenApplication, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> Iterator[Screen]: + """The application the scenario drives, with a user's hand on its display.""" + device = XTestDevice(os.environ["DISPLAY"]) + yield Screen( + bridge=screen_bridge, + render_thread=screen_render_thread, + hand=Hand(screen_bridge, device), + language=screen_application.language, + shortcuts=screen_application.shortcuts, + dialogs=screen_boundaries.dialogs, + artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), + ) + device.close() + + +def _worker_display(config: pytest.Config) -> str: + """The display this worker's scenarios draw on, started with the first of them.""" + name = config.stash.get(DISPLAY_NAME_KEY, None) + if name is not None: + return name + + display = VirtualDisplay(display_backend(os.environ), SCREEN_SIZE) + name = display.start() + config.stash[DISPLAY_KEY] = display + config.stash[DISPLAY_NAME_KEY] = name + return name + + +def _seed_viewport(state_path: Path) -> None: + """Writes the session state a first run of the scenario finds: the window filling the display.""" + state = ApplicationState( + viewport=ViewportState( + width=SCREEN_SIZE.width, + height=SCREEN_SIZE.height, + x=0, + y=0, + ) + ) + state_path.parent.mkdir(parents=True, exist_ok=True) + save_yaml_atomic(state_path, state.model_dump(mode="json")) diff --git a/tests/suite/screens/render_thread.py b/tests/suite/screens/render_thread.py new file mode 100644 index 000000000..ef678fbdc --- /dev/null +++ b/tests/suite/screens/render_thread.py @@ -0,0 +1,40 @@ +import threading +from typing import Callable + +from sampletones_application.utils.callbacks.queue import CallbackQueue + + +class QueueRenderThread: + """SampleToNES's render thread, reached through the queue its own workers post to. + + The queue drains between frames on the thread drawing them, which is where every DearPyGui call + belongs, and it counts a delay in frames drawn. + """ + + def __init__(self) -> None: + self._running = threading.Event() + self._stopped = threading.Event() + + def post( + self, + task: Callable[[], None], + *, + frames: int, + ) -> None: + CallbackQueue.add(task, delay=frames) + + def is_running(self) -> bool: + return self._running.is_set() + + def start(self) -> None: + """Marks the loop as drawing, which is when posted work starts being answered.""" + self._running.set() + + def stop(self) -> None: + """Marks the loop as stopped, so a question still waiting fails at once.""" + self._running.clear() + self._stopped.set() + + def wait_stopped(self, timeout: float) -> bool: + """Waits up to ``timeout`` seconds for the loop to stop, and says whether it did.""" + return self._stopped.wait(timeout) diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py new file mode 100644 index 000000000..77c91d326 --- /dev/null +++ b/tests/suite/screens/screen.py @@ -0,0 +1,117 @@ +from pathlib import Path +from typing import Callable, Final, Optional, TypeVar + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager +from tests.suite.scenario import BaseTestScenario, ScenarioStep +from tests.suite.screens.boundaries.dialogs import DialogKind, ScriptedFileDialogs +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.screenshot import capture +from tests.suite.screens.keyboard import press_combination, primary_combination +from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.views.display_settings import DisplaySettings +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.project import Project +from tests.suite.screens.views.tabs import Tabs + +ReadingT = TypeVar("ReadingT") + +EXPECT_TIMEOUT_SECONDS: Final[float] = 10.0 +STEP_SEPARATOR: Final[str] = " โ†’ " +SCREENSHOT_SUFFIX: Final[str] = ".png" + + +class Screen: + """What a scenario holds: SampleToNES on its display, a user's hand on it, and the views it reads. + + A scenario speaks through views, one per tab, card and dialog. A view knows where its controls + stand and reads what they show, so the scenario states gestures and expectations in a user's + terms. A reading taken while the application still moves is waited for with ``expect``, which + reads once a frame until the expectation holds. + """ + + def __init__( + self, + *, + bridge: Bridge, + render_thread: QueueRenderThread, + hand: Hand, + language: LanguageManager, + shortcuts: ShortcutManager, + dialogs: ScriptedFileDialogs, + artifacts: Path, + ) -> None: + self.bridge = bridge + self.hand = hand + self._render_thread = render_thread + self._shortcuts = shortcuts + self._dialogs = dialogs + self._artifacts = artifacts + self.tabs = Tabs(bridge, hand) + self.menu = MenuBar(bridge, language) + self.display_settings = DisplaySettings(bridge, hand, self.menu) + self.project = Project(bridge, hand, self.menu) + + def expect( + self, + reading: Callable[[], ReadingT], + holds: Callable[[ReadingT], bool], + *, + description: str, + ) -> ReadingT: + """Takes ``reading`` once a frame until ``holds`` accepts it, and returns that reading.""" + return self.bridge.expect( + reading, + holds, + description=description, + timeout=EXPECT_TIMEOUT_SECONDS, + ) + + def frames(self, count: int) -> None: + """Lets ``count`` frames pass, for a scenario whose promise is itself a number of frames.""" + self.bridge.frames(count) + + def capture(self, name: str) -> Path: + """Keeps a picture of the next frame beside the scenario's other files, as evidence of a look.""" + return capture(self.bridge, (self._artifacts / name).with_suffix(SCREENSHOT_SUFFIX)) + + def is_running(self) -> bool: + """Whether the application still draws its window.""" + return self._render_thread.is_running() + + def wait_for_exit(self) -> bool: + """Waits for the application to stop, and says whether it did within the expectation's time.""" + return self._render_thread.wait_stopped(EXPECT_TIMEOUT_SECONDS) + + def answer_next_dialog( + self, + kind: DialogKind, + path: Optional[Path], + ) -> None: + """Answers the next file dialog of ``kind`` with ``path``, or dismisses it for ``None``.""" + self._dialogs.answer(kind, path) + + def press_shortcut(self, shortcut_id: ShortcutId) -> None: + """Presses the keys the scheme in place gives ``shortcut_id`` on the real keyboard.""" + press_combination(self.hand, primary_combination(self._shortcuts.shortcut(shortcut_id))) + + def scenario(self, *steps: Callable[["Screen"], None]) -> BaseTestScenario["Screen"]: + """The ordered steps of one scenario, each named after its function in a failure it raises.""" + return BaseTestScenario( + label=STEP_SEPARATOR.join(step.__name__ for step in steps), + build=lambda: self, + steps=[ScenarioStep(label=step.__name__, action=_named(step)) for step in steps], + ) + + +def _named(step: Callable[[Screen], None]) -> Callable[[Screen], None]: + def action(screen: Screen) -> None: + try: + step(screen) + except BaseException as error: + error.add_note(f"In the step '{step.__name__}'") + raise + + return action diff --git a/tests/suite/screens/views/__init__.py b/tests/suite/screens/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/views/display_settings.py b/tests/suite/screens/views/display_settings.py new file mode 100644 index 000000000..356ca0f13 --- /dev/null +++ b/tests/suite/screens/views/display_settings.py @@ -0,0 +1,50 @@ +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, + TAG_SETTINGS_DISPLAY_BUTTON_OK, + TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, + TAG_SETTINGS_DISPLAY_DIALOG_DISCARD, + TAG_SETTINGS_DISPLAY_WINDOW, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt + + +class DisplaySettings: + """The Display settings dialog: the window mode, the pacing and the palette, with Cancel and OK.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.discard_prompt = Prompt(bridge, hand, TAG_SETTINGS_DISPLAY_DIALOG_DISCARD) + + def open(self) -> None: + """Opens the dialog from View โ–ธ Display settings.""" + self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS) + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW)).shown + + def vsync(self) -> bool: + """Whether the vertical sync box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC))) + + def toggle_vsync(self) -> None: + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) + + def cancel(self) -> None: + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, SUF_BUTTON)) + + def confirm(self) -> None: + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_OK, SUF_BUTTON)) diff --git a/tests/suite/screens/views/menus.py b/tests/suite/screens/views/menus.py new file mode 100644 index 000000000..227bf71d5 --- /dev/null +++ b/tests/suite/screens/views/menus.py @@ -0,0 +1,57 @@ +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.general import TAG_GLOBAL_WINDOW_MAIN +from sampletones_application.utils.callbacks.queue import CallbackQueue +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.items import MENU_ITEM_TYPE, MENU_TYPE, find_labelled +from tests.suite.screens.dearpygui.semantic import invoke + + +class MenuBar: + """The menu bar along the top of the window, read by the words its menus show. + + A menu and its entry are found by their labels, which the language file gives, so a scenario + names an entry the way a user reads it. DearPyGui reports a menu entry's position alone, so + choosing one runs its callback the way a click on it does. + """ + + def __init__( + self, + bridge: Bridge, + language: LanguageManager, + ) -> None: + self._bridge = bridge + self._language = language + + def choose( + self, + group: MenuElements, + item: MenuElements, + ) -> None: + """Chooses the entry ``item`` of the menu ``group``.""" + group_label = self._label(group) + item_label = self._label(item) + + def press() -> None: + menu = find_labelled( + TAG_GLOBAL_WINDOW_MAIN, + group_label, + item_type=MENU_TYPE, + ) + entry = find_labelled( + menu, + item_label, + item_type=MENU_ITEM_TYPE, + ) + invoke(entry, CallbackQueue.run) + + self._bridge.ask(press) + + def _label(self, element: MenuElements) -> str: + return self._language[ + Page.GLOBAL, + Panel.MENU, + TextType.LABEL, + element, + ] diff --git a/tests/suite/screens/views/project.py b/tests/suite/screens/views/project.py new file mode 100644 index 000000000..9f840c566 --- /dev/null +++ b/tests/suite/screens/views/project.py @@ -0,0 +1,71 @@ +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_PROJECT_SAVED, + TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_PROPERTIES_BUTTON_OK, + TAG_SETTINGS_PROPERTIES_INPUT_TITLE, + TAG_SETTINGS_PROPERTIES_WINDOW, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt + + +class ProjectProperties: + """The Project properties dialog: the title, the author and the highlights a project carries.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + + def open(self) -> None: + """Opens the dialog from File โ–ธ Project properties.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES) + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_PROPERTIES_WINDOW)).shown + + def title(self) -> str: + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE))) + + def retitle(self, title: str) -> None: + """Types ``title`` over the title the field holds.""" + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, title) + + def confirm(self) -> None: + self._hand.click(compose_tag(TAG_SETTINGS_PROPERTIES_BUTTON_OK, SUF_BUTTON)) + + +class Project: + """The project as the File menu reaches it, with the questions the application asks about it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._menu = menu + self.properties = ProjectProperties(bridge, hand, menu) + self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED) + self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_SAVED) + + def create(self) -> None: + """Starts a new project from File โ–ธ New project.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT) + + def save_as(self) -> None: + """Saves the project from File โ–ธ Save project as, which asks where.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS) diff --git a/tests/suite/screens/views/prompts.py b/tests/suite/screens/views/prompts.py new file mode 100644 index 000000000..dd19ece72 --- /dev/null +++ b/tests/suite/screens/views/prompts.py @@ -0,0 +1,60 @@ +from typing import Optional + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON, SUF_BUTTON_CANCEL, SUF_BUTTON_OK, SUF_BUTTON_SAVE +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import WindowReading, is_tag_within, read_windows + + +class MissingPromptError(AssertionError): + """Raised when a scenario answers a prompt that stands nowhere on the screen.""" + + +class Prompt: + """A question the application asks in a dialog of its own, found by the tag its kind composes under. + + Each prompt the application opens carries a fresh suffix on its kind's tag, so the prompt is the + shown window standing under that tag, and its buttons are composed from the window it stands in. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tag = tag + + def window(self) -> Optional[WindowReading]: + """The prompt's window while one is shown, and ``None`` otherwise.""" + shown = [ + window + for window in self._bridge.ask(read_windows) + if window.shown and is_tag_within(window.alias, self._tag) + ] + return shown[0] if shown else None + + def is_shown(self) -> bool: + return self.window() is not None + + def confirm(self) -> None: + """Presses the button that says yes: Discard, Load, Exit and the like.""" + self._press(SUF_BUTTON_OK) + + def save(self) -> None: + """Presses Save on a prompt that offers to save first.""" + self._press(SUF_BUTTON_SAVE) + + def cancel(self) -> None: + """Presses the button that takes the question back.""" + self._press(SUF_BUTTON_CANCEL) + + def _press(self, suffix: str) -> None: + window = self.window() + if window is None: + raise MissingPromptError(f"No prompt under '{self._tag}' stands on the screen") + + self._hand.click(compose_tag(window.alias, suffix, SUF_BUTTON)) diff --git a/tests/suite/screens/views/tabs.py b/tests/suite/screens/views/tabs.py new file mode 100644 index 000000000..5c0d2c232 --- /dev/null +++ b/tests/suite/screens/views/tabs.py @@ -0,0 +1,41 @@ +from typing import Dict, Final + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.general import ( + TAG_GLOBAL_TAB_INSTRUCTIONS, + TAG_GLOBAL_TAB_MAIN, + TAG_GLOBAL_TAB_RECONSTRUCTION, + TAG_GLOBAL_TAB_SEQUENCER, + TAG_GLOBAL_TABS, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_selected_tab + +TAB_TAGS: Final[Dict[Tab, str]] = { + Tab.MAIN: TAG_GLOBAL_TAB_MAIN, + Tab.RECONSTRUCTIONS: TAG_GLOBAL_TAB_RECONSTRUCTION, + Tab.SEQUENCER: TAG_GLOBAL_TAB_SEQUENCER, + Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS, +} +TABS_BY_TAG: Final[Dict[str, Tab]] = {tag: tab for tab, tag in TAB_TAGS.items()} + + +class Tabs: + """The tab bar across the window: which tab stands in front, and the headers that bring one forward.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def bring_to_front(self, tab: Tab) -> None: + """Clicks the header of ``tab``.""" + self._hand.click(TAB_TAGS[tab]) + + def front(self) -> Tab: + """The tab standing in front.""" + return TABS_BY_TAG[self._bridge.ask(lambda: read_selected_tab(TAG_GLOBAL_TABS))] diff --git a/uv.lock b/uv.lock index f82d19b6c..af1c18c49 100644 --- a/uv.lock +++ b/uv.lock @@ -1664,6 +1664,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -1693,6 +1705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "pyvirtualdisplay" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/9f/23e5a82987c26d225139948a224a93318d7a7c8b166d4dbe4de7426dc4e4/PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159", size = 18560, upload-time = "2022-02-13T07:57:05.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/eb/c3b8deb661cb3846db63288c99bbb39f217b7807fc8acb2fd058db41e2e6/PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e", size = 15258, upload-time = "2022-02-13T07:57:04.051Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -1852,6 +1873,9 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "python-xlib" }, + { name = "pyvirtualdisplay" }, + { name = "types-python-xlib" }, { name = "types-pyyaml" }, ] @@ -1898,6 +1922,9 @@ dev = [ { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "python-xlib", specifier = "==0.33" }, + { name = "pyvirtualdisplay", specifier = "==3.0" }, + { name = "types-python-xlib", specifier = "==0.33.0.20260724" }, { name = "types-pyyaml", specifier = "==6.0.12.20260518" }, ] @@ -2018,6 +2045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "soundfile" version = "0.13.1" @@ -2122,6 +2158,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "types-python-xlib" +version = "0.33.0.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a3/5edce1508c62748b37d103e682abe4933184998a302a45e6e17e4a9510d1/types_python_xlib-0.33.0.20260724.tar.gz", hash = "sha256:81999c1d390f8fde0113286c0c45441c37fd404c03aa67c06997ab929dbab177", size = 54664, upload-time = "2026-07-24T04:59:01.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/a4/635ae88bae98d313c44adb8c36c970a3c19094541cb8b5c65f5d9d37cd57/types_python_xlib-0.33.0.20260724-py3-none-any.whl", hash = "sha256:1d47c3d7c783c5890ad255054c8a37aa766a6d9b7e3c12f1841ea3b1721375d7", size = 67523, upload-time = "2026-07-24T04:59:00.43Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518" From 8fe210f3d9763f064862a00bbf938b728cc45b35 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 1 Oct 2026 23:39:19 +0200 Subject: [PATCH 03/19] Proved: tabs, Display settings and the exit prompts on a live screen --- Makefile | 6 +- docs/development/bugs-and-todos.md | 5 + scripts/bootstrap/layout.py | 1 + scripts/run_tests.py | 25 +++- tests/screens/dialogs/__init__.py | 0 .../screens/dialogs/test_display_settings.py | 76 +++++++++++ tests/screens/test_leaving.py | 127 ++++++++++++++++++ tests/screens/test_tabs.py | 18 +++ tests/unit/scripts/test_run_tests.py | 22 ++- 9 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 tests/screens/dialogs/__init__.py create mode 100644 tests/screens/dialogs/test_display_settings.py create mode 100644 tests/screens/test_leaving.py create mode 100644 tests/screens/test_tabs.py diff --git a/Makefile b/Makefile index c856e87ad..58608083e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install system-deps build release run calibration tracker-playback clean pre-commit test test-docs benchmarks lint format +.PHONY: help setup install system-deps build release run calibration tracker-playback clean pre-commit test test-docs benchmarks screens lint format ifeq ($(OS),Windows_NT) PYTHON := python @@ -25,6 +25,7 @@ help: @echo $(Q) make test - Run the test suite with coverage$(Q) @echo $(Q) make test-docs - Run the doctests$(Q) @echo $(Q) make benchmarks - Run the measured-duration suite$(Q) + @echo $(Q) make screens - Run the screen scenarios: the application driven on a virtual display (needs Xvfb)$(Q) @echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q) @echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @@ -74,6 +75,9 @@ test-docs: benchmarks: $(PYTHON) scripts/run_tests.py benchmarks +screens: + $(PYTHON) scripts/run_tests.py screens + lint: $(PYTHON) scripts/lint.py $(ARGS) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8dca0bdcd..e709fd70c 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -146,3 +146,8 @@ currently out of line. An entry leaves when the code meets the contract again. moved. ## Bugs + +* Leaving the application on a machine that offers no output device fails. The shutdown saves the + session and asks the audio device manager for its current device, and with no device selected it + raises `ValueError: No audio device selected`. The screen scenario in `tests/screens/test_leaving.py` + reproduces it as a known failure. diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py index 68f1acf1e..4be482b5e 100644 --- a/scripts/bootstrap/layout.py +++ b/scripts/bootstrap/layout.py @@ -5,6 +5,7 @@ PROJECT_FILE: Final[str] = "pyproject.toml" SOURCE_DIRECTORY: Final[str] = "src" BENCHMARKS_DIRECTORY: Final[str] = "tests/benchmarks" +SCREENS_DIRECTORY: Final[str] = "tests/screens" DISTRIBUTION: Final[str] = "bin" BUNDLES: Final[str] = "bundles" BUILD_ENVIRONMENT: Final[str] = ".venv-build" diff --git a/scripts/run_tests.py b/scripts/run_tests.py index f1d213741..b324cb17f 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -3,24 +3,29 @@ import sys from typing import Dict, Final, Sequence, Tuple -from bootstrap.layout import BENCHMARKS_DIRECTORY, SOURCE_DIRECTORY, repository_root +from bootstrap.layout import BENCHMARKS_DIRECTORY, SCREENS_DIRECTORY, SOURCE_DIRECTORY, repository_root from bootstrap.passes import Pass, run_pass from bootstrap.processes import run SUITE: Final[str] = "suite" DOCTESTS: Final[str] = "doctests" BENCHMARKS: Final[str] = "benchmarks" +SCREENS: Final[str] = "screens" DEFAULT_WORKERS: Final[str] = "6" +SCREEN_WORKERS: Final[str] = "4" PYTEST: Final[Tuple[str, ...]] = ("uv", "run", "python", "-m", "pytest") def planned_passes(workers: str) -> Dict[str, Pass]: - """The passes a test run is made of, by name: the covered suite, the doctests, the benchmarks. + """The passes a test run is made of, by name: the covered suite, the doctests, the benchmarks + and the screen scenarios. Each pass is a target and a hook of its own, so a failure names the pass it belongs to. The covered suite runs across ``workers`` pytest workers. The benchmarks run serial, uncovered and with their output shown, so a measured duration is the code's own cost and its reading - reaches the terminal. + reaches the terminal. The screen scenarios drive the running application on a display of + each worker's own, each scenario in a process of its own, so they run uncovered across a few + workers, each drawing its frames on the processor. Args: workers: The worker count for the covered suite, or ``auto`` for one per processor. @@ -32,7 +37,14 @@ def planned_passes(workers: str) -> Dict[str, Pass]: Pass( SUITE, "Running pytest with coverage...", - (*PYTEST, "-n", workers, "--cov", f"--ignore={BENCHMARKS_DIRECTORY}"), + ( + *PYTEST, + "-n", + workers, + "--cov", + f"--ignore={BENCHMARKS_DIRECTORY}", + f"--ignore={SCREENS_DIRECTORY}", + ), ), Pass( DOCTESTS, @@ -44,6 +56,11 @@ def planned_passes(workers: str) -> Dict[str, Pass]: "Running benchmarks...", (*PYTEST, BENCHMARKS_DIRECTORY, "--no-cov", "-s"), ), + Pass( + SCREENS, + "Running screen scenarios...", + (*PYTEST, SCREENS_DIRECTORY, "-n", SCREEN_WORKERS, "--no-cov"), + ), ) return {current.name: current for current in passes} diff --git a/tests/screens/dialogs/__init__.py b/tests/screens/dialogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/dialogs/test_display_settings.py b/tests/screens/dialogs/test_display_settings.py new file mode 100644 index 000000000..b75164f47 --- /dev/null +++ b/tests/screens/dialogs/test_display_settings.py @@ -0,0 +1,76 @@ +import operator +from typing import List + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + + +class TestDiscardingADisplayChange: + """Cancelling Display settings with a change pending asks before the change is thrown away.""" + + def test_keep_editing_brings_the_dialog_back_and_discard_restores_it(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the vertical sync box flipped") + + def cancel_asks_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(prompt.is_shown, bool, description="the discard prompt") + assert not settings.is_shown() + + def keep_editing(screen: Screen) -> None: + prompt.cancel() + + screen.expect(settings.is_shown, bool, description="the dialog back on the screen") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def discard(screen: Screen) -> None: + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt again") + + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + assert not prompt.is_shown() + + def reopen_as_it_was(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + + assert settings.vsync() == original[0] + + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed at once") + + screen.scenario( + change_vertical_sync, + cancel_asks_first, + keep_editing, + discard, + reopen_as_it_was, + ).run() + + +class TestTheKeyboardOnDisplaySettings: + """The keys alone answer Display settings.""" + + def test_escape_closes_an_unchanged_dialog(self, screen: Screen) -> None: + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + assert not settings.discard_prompt.is_shown() diff --git a/tests/screens/test_leaving.py b/tests/screens/test_leaving.py new file mode 100644 index 000000000..dfc07dec6 --- /dev/null +++ b/tests/screens/test_leaving.py @@ -0,0 +1,127 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.project import ProjectContainer +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.boundaries.audio import OutputDevice +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen + +PROJECT_FILENAME: Final[str] = "Leaving.stp" +NEW_TITLE: Final[str] = "Leaving title" +SETTLING_FRAMES: Final[int] = 10 + + +def save_a_new_project(screen: Screen) -> None: + project = screen.project + project.create() + screen.answer_next_dialog(DialogKind.SAVE, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + project.save_as() + + screen.expect(project.saved_notice.is_shown, bool, description="the project saved notice") + project.saved_notice.confirm() + screen.expect(project.saved_notice.is_shown, operator.not_, description="the notice gone") + + +def retitle_the_project(screen: Screen) -> None: + properties = screen.project.properties + properties.open() + screen.expect(properties.is_shown, bool, description="the Project properties dialog") + + properties.retitle(NEW_TITLE) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + + +def ask_to_leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + return ProjectContainer.load(PROJECTS_DIRECTORY / PROJECT_FILENAME).info.title + + +class TestLeavingWithAnUnsavedProject: + """Leaving the application with changes to a saved project asks about them first.""" + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_open_and_the_change_unsaved(self, screen: Screen) -> None: + def cancel(screen: Screen) -> None: + screen.project.unsaved_prompt.cancel() + + screen.expect(screen.project.unsaved_prompt.is_shown, operator.not_, description="the prompt gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert saved_title() != NEW_TITLE + + def leave_without_saving(screen: Screen) -> None: + ask_to_leave(screen) + + screen.project.unsaved_prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() != NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + cancel, + leave_without_saving, + ).run() + + +class TestLeavingFromTheKeyboard: + """The exit's questions answer to the keys alone, from the first press to the last.""" + + def test_tab_and_enter_save_the_change_and_leave(self, screen: Screen) -> None: + def save_from_the_keyboard(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + save_from_the_keyboard, + ).run() + + +class TestLeavingWhereNothingCanPlay: + """A machine offering no output device opens the application and leaves it cleanly.""" + + @pytest.fixture + def output_device(self) -> OutputDevice: + return OutputDevice.NONE + + @pytest.mark.xfail( + strict=True, + raises=ValueError, + reason="bugs-and-todos ยง Bugs: leaving the application where nothing can play", + ) + def test_the_application_leaves_without_an_error(self, screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") diff --git a/tests/screens/test_tabs.py b/tests/screens/test_tabs.py new file mode 100644 index 000000000..6f6be3310 --- /dev/null +++ b/tests/screens/test_tabs.py @@ -0,0 +1,18 @@ +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.screen import Screen + + +class TestTabs: + """The tab bar across the window brings a tab forward at a click on its header.""" + + def test_each_header_brings_its_tab_forward(self, screen: Screen) -> None: + for tab in (Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS, Tab.MAIN): + screen.tabs.bring_to_front(tab) + + front = screen.expect( + screen.tabs.front, + tab.__eq__, + description=f"the {tab} tab in front", + ) + + assert front == tab diff --git a/tests/unit/scripts/test_run_tests.py b/tests/unit/scripts/test_run_tests.py index 35d9fbd73..f371d201e 100644 --- a/tests/unit/scripts/test_run_tests.py +++ b/tests/unit/scripts/test_run_tests.py @@ -3,7 +3,7 @@ import pytest -from bootstrap.layout import BENCHMARKS_DIRECTORY +from bootstrap.layout import BENCHMARKS_DIRECTORY, SCREENS_DIRECTORY from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.scripts import load_script @@ -16,12 +16,24 @@ def test_every_pass_is_found_under_its_name(self) -> None: passes = run_tests.planned_passes(run_tests.DEFAULT_WORKERS) assert all(name == current.name for name, current in passes.items()) - assert set(passes) == {run_tests.SUITE, run_tests.DOCTESTS, run_tests.BENCHMARKS} + assert set(passes) == {run_tests.SUITE, run_tests.DOCTESTS, run_tests.BENCHMARKS, run_tests.SCREENS} - def test_the_suite_is_covered_across_the_workers_and_leaves_the_benchmarks_out(self) -> None: + def test_the_suite_is_covered_across_the_workers_and_leaves_the_other_passes_out(self) -> None: command = run_tests.planned_passes("auto")[run_tests.SUITE].command - assert command[-4:] == ("-n", "auto", "--cov", f"--ignore={BENCHMARKS_DIRECTORY}") + assert command[-5:] == ( + "-n", + "auto", + "--cov", + f"--ignore={BENCHMARKS_DIRECTORY}", + f"--ignore={SCREENS_DIRECTORY}", + ) + + def test_the_screen_scenarios_run_across_their_own_workers_uncovered(self) -> None: + command = run_tests.planned_passes("auto")[run_tests.SCREENS].command + + assert SCREENS_DIRECTORY in command + assert command[-3:] == ("-n", run_tests.SCREEN_WORKERS, "--no-cov") def test_the_doctests_read_the_sources_uncovered(self) -> None: command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS)[run_tests.DOCTESTS].command @@ -49,7 +61,7 @@ class TestCase(BaseRegularTestCase): ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) - def test_a_pass_outside_the_three_is_refused(self, test_case: TestCase) -> None: + def test_a_pass_outside_the_planned_ones_is_refused(self, test_case: TestCase) -> None: with pytest.raises(SystemExit) as exit_info: run_tests.main(test_case.argv) From a4eb9d2fbb486e203b382cf8340e301ad5616762 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 1 Oct 2026 23:41:45 +0200 Subject: [PATCH 04/19] Wired: the screen scenarios into the pre-push hooks and the Linux CI run --- .github/workflows/ci.yml | 13 +++++++++++++ .pre-commit-config.yaml | 10 ++++++++++ docs/development/bugs-and-todos.md | 4 ++++ scripts/bootstrap/platforms/linux.py | 2 ++ 4 files changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6787bc557..961f204f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,3 +90,16 @@ jobs: if: ${{ !cancelled() && steps.environment.outcome == 'success' }} shell: bash run: $PYTHON scripts/run_tests.py benchmarks + + - name: Run the screen scenarios (Linux) + id: screens + if: ${{ !cancelled() && steps.environment.outcome == 'success' && runner.os == 'Linux' }} + shell: bash + run: $PYTHON scripts/run_tests.py screens + + - name: Keep what the failed screen scenarios left + if: ${{ failure() && steps.screens.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: screens-${{ matrix.os }}-py${{ matrix.python }} + path: build/screens/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3d32077d..c79d601d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -145,3 +145,13 @@ repos: stages: - pre-push pass_filenames: false + + - id: screens + name: screens + entry: make screens + language: system + types: + - python + stages: + - pre-push + pass_filenames: false diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index e709fd70c..23be83262 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -80,6 +80,10 @@ dimension the import starts carrying. `referee/test_axioms.py` does for the referees. * API documentation * Code documentation +* Screen scenarios on Windows and macOS. They run on Linux alone: a scenario's pointer and keys reach the + application through X11, and whether DearPyGui opens its window on GitHub's Windows and macOS runners is + unverified. A run there starts from a spike that opens the application on each runner and presses one + control through the callback a click runs. * What a build makes of the configuration and the session state an older one left behind. Neither has a version, so neither travels an upgrade chain or has an archived corpus. A `state.yaml` naming a panel that has since gone, or a configuration missing a setting added since, is read by whatever each loader diff --git a/scripts/bootstrap/platforms/linux.py b/scripts/bootstrap/platforms/linux.py index 2c0691ac2..4a1a0ac4d 100644 --- a/scripts/bootstrap/platforms/linux.py +++ b/scripts/bootstrap/platforms/linux.py @@ -24,6 +24,8 @@ "libxrandr2", "libxrender1", "libxxf86vm1", + "libgl1-mesa-dri", + "xvfb", ) BUNDLING: Final[Bundling] = Bundling( icon=PNG_ICON, From accb3ed450e4ed44328098d1946e4bd86c0c3b51 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 1 Oct 2026 23:43:32 +0200 Subject: [PATCH 05/19] Documented: how a screen scenario is written, run and watched --- docs/development/application/screens.md | 159 ++++++++++++++++++++++++ docs/development/guidelines.md | 1 + docs/index.md | 1 + 3 files changed, 161 insertions(+) create mode 100644 docs/development/application/screens.md diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md new file mode 100644 index 000000000..04a557e68 --- /dev/null +++ b/docs/development/application/screens.md @@ -0,0 +1,159 @@ +# Screen Scenarios + +This document governs the screen tier: tests that run the whole application, draw its frames on a display, +and press its controls the way a user does. It covers `tests/screens/`, where the scenarios live, and +`tests/suite/screens/`, which drives them. Consult it before writing a scenario, and when a change to the +interface needs proving that no lower tier can give. + +--- + +## Principles + +### 1. A behavior is proven at the lowest tier that sees it + +Most behavior is proven below this tier. Logic is tested without DearPyGui, a widget on a real context +with simulated frames, and the whole application headless with its display calls stood in for. None of +them draws a frame. A screen scenario covers what only a drawn frame shows: where a control stands and +whether it fits, what a frame-lag contract decides, which scope a real key press reaches, hover, drag and +double-click, the exit and its teardown, and what a restart restores. A rule a screen depends on gets its +own case below, and the scenario keeps only what the screen adds. + +### 2. A scenario drives the application a user runs + +A scenario starts the application as `sampletones open` does and runs its own render loop unchanged. The +pointer and the keyboard are real input sent to the display, so a press travels the path a user's press +travels. A scenario reads what the screen shows and what the application wrote to disk. It reaches the +application through what the application makes public: tags, language keys, shortcuts, services and the +modal queue's snapshot. A seam a scenario needs and cannot find is added to the application as a public +one, which keeps the scenario independent of how the code is arranged inside. + +### 3. Each scenario runs in a world of its own + +Every scenario runs in a fresh process with a home directory of its own, so settings, session state and +the documents folder start empty. The process ends with the scenario, so the exit and its teardown are part +of what the scenario proves, and a crash on the way out fails it. A worker draws on an X server of its own, +so its scenarios take only its own input. + +### 4. A scenario waits for what it expects + +Waiting is for a reading to hold. An expectation reads once a frame until it holds or its time runs out, +and a gesture waits for its control to be reachable and to stand still. A fixed sleep hides a race, and a +retry hides a bug: a scenario that passes only sometimes is a bug. A count of frames appears in a scenario +only where the count is the contract itself. + +### 5. Every scenario is held to the same promises + +Every scenario is held to the same after-checks. A failing gesture is logged and swallowed so the interface +keeps running, which is why the checks read the log as well as the screen. + +- **Quiet.** The application logged no error and no thread let an exception escape. +- **Contained.** The application started no program, and every file dialog it opened had an answer + waiting. +- **Settled.** No modal conversation is left open, and every window lies inside the viewport. +- **Leaving cleanly.** The application leaves through the Exit shortcut without asking anything, stops, + and writes its session state. + +A known bug is a case marked `xfail(strict=True)` that names its [ledger](../bugs-and-todos.md) entry. The +mark makes the run fail once the bug is fixed, until the mark goes. + +### 6. The DearPyGui layer knows nothing of SampleToNES + +`tests/suite/screens/dearpygui/` drives any DearPyGui application: the process per scenario, the display, +the input, the readings and the waiting. The SampleToNES layer around it holds what belongs to this +application: the views, the stand-ins at its boundaries and the after-checks. A case in +`tests/integration/tooling/` holds the direction. The DearPyGui layer grows only when a scenario here needs +something, and it moves to a project of its own the day a second application wants it. + +## How a scenario runs + +The parent pytest run starts each scenario as a child pytest run of that one test, in a fresh interpreter. +The child writes its reports as it goes, and the parent reports them as its own, so selecting, rerunning and +strict expected failures behave as for any test. A child that crashes or runs out of time is reported as a +failure carrying its output. + +Inside the child, the application is built on the main thread, where its render loop runs. The scenario +runs on a thread of its own. Every reading and every gesture crosses to the render thread through the +queue the application's own workers post to, between two frames ([the render thread](render-thread.md)). + +## Gestures + +A pointer gesture aims at the middle of a control once the control is in reach: it exists, it and every +container around it are shown, it was drawn in the last frame, it answers a press, its box lies inside the +viewport, and no modal window of another tree holds the screen. The control must also stand where it stood a +frame before, since a dialog sized by its content settles its place over its first frames +([dialogs](dialogs.md)). + +Keys are pressed on the real keyboard, with modifiers held a frame before the key. A scenario names an +action by its `ShortcutId`, and the keys come from the scheme in place. + +DearPyGui reports a position alone for a menu entry, so choosing one runs its callback the way a click does, +through the queue's own error reporting. + +## Views + +A scenario speaks through views, one per tab, card and dialog, under `tests/suite/screens/views/`. A view +knows its controls by their tags and its words by their language keys. A tag that moves changes one view, +and a scenario reads as the gestures a user makes. + +## The boundaries + +- **File dialogs.** A native dialog stops the queue while it stands, so a scenario queues its answer before + the gesture that opens the dialog, and the stand-in answers at once. +- **Audio.** The default output device plays into silence in real time, so playback runs and nothing is + heard. A scenario can start on a machine offering no device at all. +- **Programs.** An audit hook refuses every program the application tries to start: a file manager, a + browser or a dialog tool would open on the desktop around the run. A shared library lookup passes. + +## Questions a GUI change asks + +Before a change to the interface gets its scenarios, ask which of these it meets, and provoke it in a +scenario: + +- **Render thread.** What does it touch in DearPyGui away from the render thread? +- **Teardown.** What happens when the window closes while it runs? +- **Modal over modal.** What if its dialog opens while another stands, waits or answers? +- **Frame lag.** Which geometry does it read in the frame that changes it? +- **In flight.** What if the user acts while its work is still running? +- **Unsaved work.** What does every path that replaces or closes a document ask? +- **Twice.** What does the same gesture do twice, and a double click against a single one? +- **Key scope.** Which scope claims its keys: a field, a modal, a panel, a collapsed card or a rebound key? +- **Palette.** Does a live palette swap repaint it? +- **Restart.** What of it survives a restart? +- **Scale.** Does it still answer with a large folder or a long list in it? +- **Failure.** What does the user read when a file is missing, old or truncated, or a write fails? + +The list holds questions, never history. A fixed bug becomes a scenario, a step, a stand-in or an +after-check, and the list keeps only the general question that leads to it. A question joins only when no +question here would have led to the test that caught a bug, and near-duplicates merge, so the list stays +short enough to read before every change. + +A bug fix's scenario is shown failing on the code before the fix, and the pull request says so. + +## Running and watching + +`make screens` runs every scenario across a few workers, each on an Xvfb of its own. `make system-deps` +installs Xvfb with the other development packages on Linux, and a missing server is named when the run +starts. The scenarios run on Linux, and [the ledger](../bugs-and-todos.md) holds the other platforms. + +To follow a scenario as it plays, draw on Xephyr, which opens its screen as a window on the desktop: + +``` +SAMPLETONES_SCREENS_DISPLAY=xephyr uv run python -m pytest tests/screens --no-cov -k display_settings +``` + +Each scenario keeps its files under `build/screens/`, in a folder named after the test: the home the +application lived in, and a screenshot of the last frame when the scenario failed. `screen.capture` keeps a +picture as evidence of a look, for a pull request rather than an assertion. + +## Who governs what + +| Concern | Owner | +|---|---| +| A scenario per process, its reports | `tests/suite/screens/dearpygui/isolation.py`, `plugin.py` | +| The render thread crossing and waiting | `tests/suite/screens/dearpygui/bridge.py` | +| What a user can reach, and the gestures | `tests/suite/screens/dearpygui/reach.py`, `hand.py`, `semantic.py` | +| The display a worker draws on | `tests/suite/screens/dearpygui/display.py` | +| The application under test and how it ends | `tests/suite/screens/application.py` | +| The stand-ins at the boundaries | `tests/suite/screens/boundaries/` | +| The after-checks | `tests/suite/screens/checks.py` | +| What a scenario holds and reads | `tests/suite/screens/screen.py`, `views/` | diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 7df38780e..5383e33f0 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -90,6 +90,7 @@ These rules govern the Python in this repository. They complement 1. A test file mirrors the ownership of the code it exercises. 1. When functionality moves between packages, move its direct unit tests in the same change. +1. **What only a drawn frame shows is proven by a screen scenario.** A behavior is proven at the lowest tier that sees its outcome, and a scenario in `tests/screens/` covers what a running, rendered application adds: geometry, frame timing, real input and the exit. `make screens` runs the scenarios, and [screen scenarios](application/screens.md) says how they are written. 1. **A test whose assertion is a measured duration lives in `tests/benchmarks/`.** The gated suite runs across several workers and under coverage, which multiplies the cost of the code being measured. Benchmarks run in a pass of their own, serial and uncovered, where the reading is the code's own cost. `make test` runs the covered suite, and `make benchmarks` runs the measured pass. 1. Parametrize tests that share a body, using a test-case dataclass. 1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. diff --git a/docs/index.md b/docs/index.md index f8a39a680..5a15168b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -87,6 +87,7 @@ whole repository. The pages about the graphical application and about releases e - [Reconstruction browser](development/application/browser.md) โ€” how a reconstructions directory becomes the tree both browser tabs show, and what narrows it. - [Stems in the application](development/application/stems.md) โ€” the Stems card, and what an edit or a removal does to the per-frame record. - [Editing the open reconstruction](development/application/reconstruction-edits.md) โ€” how the Reconstructions tab takes the reader's edits one step at a time, and what waits for them. +- [Screen scenarios](development/application/screens.md) โ€” tests that run the whole application on a display and press its controls the way a user does. - [Configuration](development/application/config-organization.md) โ€” how the YAML configuration package is laid out. ### Releases From 5956b6a5906a3f8ff7a89e9b5c6e174ada8c65d0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 09:58:34 +0200 Subject: [PATCH 06/19] Built: the scenario's seeded home, the window manager's close and gestures that confirm they arrived --- docs/development/application/screens.md | 69 ++++++++++++++--- tests/screens/application/__init__.py | 0 tests/screens/application/test_closing.py | 88 +++++++++++++++++++++ tests/screens/conftest.py | 2 + tests/screens/test_leaving.py | 24 +----- tests/suite/screens/application.py | 13 ++++ tests/suite/screens/boundaries/errors.py | 31 +++++++- tests/suite/screens/checks.py | 10 ++- tests/suite/screens/dearpygui/hand.py | 89 +++++++++++++++++++--- tests/suite/screens/dearpygui/isolation.py | 30 ++++++-- tests/suite/screens/dearpygui/items.py | 13 ++++ tests/suite/screens/dearpygui/keys.py | 5 ++ tests/suite/screens/dearpygui/windows.py | 69 +++++++++++++++++ tests/suite/screens/dearpygui/xtest.py | 4 + tests/suite/screens/plugin.py | 32 ++++---- tests/suite/screens/screen.py | 7 ++ tests/suite/screens/steps/__init__.py | 0 tests/suite/screens/steps/project.py | 41 ++++++++++ tests/suite/screens/views/prompts.py | 10 ++- tests/suite/screens/world.py | 80 +++++++++++++++++++ 20 files changed, 543 insertions(+), 74 deletions(-) create mode 100644 tests/screens/application/__init__.py create mode 100644 tests/screens/application/test_closing.py create mode 100644 tests/suite/screens/dearpygui/windows.py create mode 100644 tests/suite/screens/steps/__init__.py create mode 100644 tests/suite/screens/steps/project.py create mode 100644 tests/suite/screens/world.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 04a557e68..97737f7d7 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -29,19 +29,48 @@ one, which keeps the scenario independent of how the code is arranged inside. ### 3. Each scenario runs in a world of its own -Every scenario runs in a fresh process with a home directory of its own, so settings, session state and -the documents folder start empty. The process ends with the scenario, so the exit and its teardown are part -of what the scenario proves, and a crash on the way out fails it. A worker draws on an X server of its own, -so its scenarios take only its own input. - -### 4. A scenario waits for what it expects +Every scenario runs in a fresh process started in a home directory of its own, so settings, session state +and the documents folder hold only what the scenario put there. The process ends with the scenario, so the +exit and its teardown are part of what the scenario proves, and a crash on the way out fails it. A worker +draws on an X server of its own, so its scenarios take only its own input. + +The `world` fixture says what the home holds before the first frame: the session a previous run left, the +settings, and the recordings, documents and libraries the scenario works on. Each file is written by the +model and the manager the application reads it with, so a seeded home is one a real run could have left. A +scenario class overrides `world` to seed its own. + +### 4. A scenario is written to break the application + +A scenario that only confirms the path its author had in mind proves little. Each rule a scenario proves +gets a positive example and a negative or boundary one, with real values, and each area is attacked along +the [questions](#questions-a-gui-change-asks) below. + +- **A negative ends with its witness.** After the gesture that should change nothing, the same path is used + once more where it should act, and then the whole state is asserted: the count is one lower and the row + that left is the one named. A late effect of the negative then shows in the count. Waiting a fixed + window instead would only restate the application's own delays. +- **A gesture confirms that it arrived** (see [Gestures](#gestures)), so a negative never passes because + its input was lost. +- **A frame-lag promise is read on every frame** between the gestures, since a reading once a frame until + it holds misses a flash that lasts one frame. +- **Where several states meet several doors, one suite walks the table**: every document state against + every door that closes it, every kind of broken file against every door that opens one. +- **An expected value comes from the rule**: a language key with its arguments, a palette token, or a hand + computation written in the test. A value the code under test answered proves nothing about that code. +- **A scenario that could pass vacuously is shown failing** on a fault planted for the purpose: a negative, + an attack, or a check that reads state the gesture might never reach. + +A break of something a document or a docstring states is recorded as a known bug, below, and its fix is +separate work. A break of behavior nothing states is a question for the maintainer. + +### 5. A scenario waits for what it expects Waiting is for a reading to hold. An expectation reads once a frame until it holds or its time runs out, and a gesture waits for its control to be reachable and to stand still. A fixed sleep hides a race, and a retry hides a bug: a scenario that passes only sometimes is a bug. A count of frames appears in a scenario only where the count is the contract itself. -### 5. Every scenario is held to the same promises +### 6. Every scenario is held to the same promises Every scenario is held to the same after-checks. A failing gesture is logged and swallowed so the interface keeps running, which is why the checks read the log as well as the screen. @@ -51,12 +80,13 @@ keeps running, which is why the checks read the log as well as the screen. waiting. - **Settled.** No modal conversation is left open, and every window lies inside the viewport. - **Leaving cleanly.** The application leaves through the Exit shortcut without asking anything, stops, - and writes its session state. + and writes its session state. Its background work winds down within the exit's deadline, and no thread + of its own outlives the exit. A known bug is a case marked `xfail(strict=True)` that names its [ledger](../bugs-and-todos.md) entry. The mark makes the run fail once the bug is fixed, until the mark goes. -### 6. The DearPyGui layer knows nothing of SampleToNES +### 7. The DearPyGui layer knows nothing of SampleToNES `tests/suite/screens/dearpygui/` drives any DearPyGui application: the process per scenario, the display, the input, the readings and the waiting. The SampleToNES layer around it holds what belongs to this @@ -84,7 +114,13 @@ frame before, since a dialog sized by its content settles its place over its fir ([dialogs](dialogs.md)). Keys are pressed on the real keyboard, with modifiers held a frame before the key. A scenario names an -action by its `ShortcutId`, and the keys come from the scheme in place. +action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key, +so a key held across a slow frame arrives as one press. + +A gesture confirms that it arrived. Before a button goes down, the control must report the pointer resting +on it: DearPyGui calls a control visible while the region it scrolls in clips it, and a press there lands +on something else. While a button or a named key is held, the application must read it as down. A press +lost on the way fails where it was lost. DearPyGui reports a position alone for a menu entry, so choosing one runs its callback the way a click does, through the queue's own error reporting. @@ -103,6 +139,15 @@ and a scenario reads as the gestures a user makes. heard. A scenario can start on a machine offering no device at all. - **Programs.** An audit hook refuses every program the application tries to start: a file manager, a browser or a dialog tool would open on the desktop around the run. A shared library lookup passes. +- **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request + a title bar's close button sends. + +## What survives a restart + +What a restart keeps is proven in two halves, each a scenario on a fresh home. In the first, the application +leaves, and the scenario reads the files it wrote through their models. In the second, the world seeds the +same value through the same model, and the scenario reads it on the screen. A writer and a reader that +disagree fail one of the halves. ## Questions a GUI change asks @@ -156,4 +201,6 @@ picture as evidence of a look, for a pull request rather than an assertion. | The application under test and how it ends | `tests/suite/screens/application.py` | | The stand-ins at the boundaries | `tests/suite/screens/boundaries/` | | The after-checks | `tests/suite/screens/checks.py` | -| What a scenario holds and reads | `tests/suite/screens/screen.py`, `views/` | +| What a scenario holds and reads | `tests/suite/screens/screen.py`, `views/`, `steps/` | +| What a scenario's home holds | `tests/suite/screens/world.py` | +| The window manager's requests | `tests/suite/screens/dearpygui/windows.py` | diff --git a/tests/screens/application/__init__.py b/tests/screens/application/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/test_closing.py b/tests/screens/application/test_closing.py new file mode 100644 index 000000000..f36105601 --- /dev/null +++ b/tests/screens/application/test_closing.py @@ -0,0 +1,88 @@ +import operator +from typing import Final + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title + +PROJECT_FILENAME: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" +NEW_TITLE: Final[str] = "Closing title" + + +def save_a_titled_project(screen: Screen) -> None: + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +def change_the_title(screen: Screen) -> None: + retitle_project(screen, NEW_TITLE) + + +def close_asks_first(screen: Screen) -> None: + screen.close_window() + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +class TestClosingTheWindow: + """The close button on the window's title bar leaves the application the way Exit does.""" + + def test_an_application_with_nothing_unsaved_leaves_at_once(self, screen: Screen) -> None: + screen.close_window() + + assert screen.wait_for_exit() + + +class TestClosingTheWindowOverAnUnsavedProject: + """Closing the window with an unsaved project asks about it first, and asks again at each close.""" + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_and_the_file_and_the_next_close_asks_again(self, screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + def cancel(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the prompt gone") + + def close_again(screen: Screen) -> None: + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the unsaved project prompt again") + assert len(prompt.shown_windows()) == 1 + assert screen.is_running() + assert saved_title() == SAVED_TITLE + + def discard_and_leave(screen: Screen) -> None: + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() == SAVED_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + cancel, + close_again, + discard_and_leave, + ).run() diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py index 478697f23..994735738 100644 --- a/tests/screens/conftest.py +++ b/tests/screens/conftest.py @@ -10,6 +10,7 @@ screen_bridge, screen_render_thread, startup, + world, ) __all__ = [ @@ -24,4 +25,5 @@ "screen_bridge", "screen_render_thread", "startup", + "world", ] diff --git a/tests/screens/test_leaving.py b/tests/screens/test_leaving.py index dfc07dec6..7c36c093a 100644 --- a/tests/screens/test_leaving.py +++ b/tests/screens/test_leaving.py @@ -5,11 +5,10 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.project import ProjectContainer from sampletones_shared.paths.user import PROJECTS_DIRECTORY from tests.suite.screens.boundaries.audio import OutputDevice -from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, save_new_project, saved_project_title PROJECT_FILENAME: Final[str] = "Leaving.stp" NEW_TITLE: Final[str] = "Leaving title" @@ -17,26 +16,11 @@ def save_a_new_project(screen: Screen) -> None: - project = screen.project - project.create() - screen.answer_next_dialog(DialogKind.SAVE, PROJECTS_DIRECTORY / PROJECT_FILENAME) - - project.save_as() - - screen.expect(project.saved_notice.is_shown, bool, description="the project saved notice") - project.saved_notice.confirm() - screen.expect(project.saved_notice.is_shown, operator.not_, description="the notice gone") + save_new_project(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) def retitle_the_project(screen: Screen) -> None: - properties = screen.project.properties - properties.open() - screen.expect(properties.is_shown, bool, description="the Project properties dialog") - - properties.retitle(NEW_TITLE) - properties.confirm() - - screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + retitle_project(screen, NEW_TITLE) def ask_to_leave(screen: Screen) -> None: @@ -46,7 +30,7 @@ def ask_to_leave(screen: Screen) -> None: def saved_title() -> str: - return ProjectContainer.load(PROJECTS_DIRECTORY / PROJECT_FILENAME).info.title + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) class TestLeavingWithAnUnsavedProject: diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application.py index 84cae1b2e..9540b05b8 100644 --- a/tests/suite/screens/application.py +++ b/tests/suite/screens/application.py @@ -1,3 +1,4 @@ +import threading from dataclasses import dataclass from pathlib import Path from typing import Final, List, Optional @@ -18,8 +19,10 @@ quiet_findings, read_screen_state, screen_findings, + surviving_thread_findings, ) from tests.suite.screens.dearpygui.bridge import Bridge, RenderThreadStoppedError +from tests.suite.screens.dearpygui.hosting import SCENARIO_THREAD_NAME from tests.suite.screens.dearpygui.screenshot import capture from tests.suite.screens.keyboard import primary_combination from tests.suite.screens.render_thread import QueueRenderThread @@ -115,6 +118,7 @@ def finish(self, *, failed: bool) -> None: def close(self) -> None: findings = quiet_findings(self._boundaries.errors)[self._reported_errors :] + findings.extend(surviving_thread_findings(_application_threads())) if not self._state_path.exists(): findings.append(f"The application stopped without writing its session state to {self._state_path}") @@ -158,3 +162,12 @@ def _capture_failure(self) -> List[str]: return [f"The failure screenshot could not be taken: {error}"] return [] + + +def _application_threads() -> List[threading.Thread]: + """The threads running beside the main one, the scenario's own aside.""" + return [ + thread + for thread in threading.enumerate() + if thread is not threading.main_thread() and thread.name != SCENARIO_THREAD_NAME + ] diff --git a/tests/suite/screens/boundaries/errors.py b/tests/suite/screens/boundaries/errors.py index bcbeb9cbf..257f8e4ba 100644 --- a/tests/suite/screens/boundaries/errors.py +++ b/tests/suite/screens/boundaries/errors.py @@ -1,19 +1,26 @@ +import inspect import logging import threading -from typing import List, Tuple +from types import CodeType +from typing import Final, List, Tuple +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_shared.application import SAMPLETONES_NAME +EXIT_JOIN_CODE: Final[CodeType] = SingleThreadExecutor.join_all.__code__ + class ErrorRecords(logging.Handler): """Every error the application logs, and every exception one of its threads lets escape. A gesture's callback that raises is logged and swallowed so the interface keeps running, which is why a scenario reads this record: a click that failed this way leaves the screen looking calm. + The warning that background work outlived the exit's deadline counts as an error too, since the + teardown destroys what that work still reaches. """ def __init__(self) -> None: - super().__init__(level=logging.ERROR) + super().__init__(level=logging.WARNING) self._messages: List[str] = [] self._lock = threading.Lock() @@ -27,10 +34,10 @@ def messages(self) -> Tuple[str, ...]: return tuple(self._messages) def emit(self, record: logging.LogRecord) -> None: - if record.levelno < logging.ERROR: + if record.levelno < logging.ERROR and not _logged_by_the_exit_join(): return - self._record(self.format(record)) + self._record(f"{record.levelname} {self.format(record)}") def _escaped(self, arguments: threading.ExceptHookArgs) -> None: thread = arguments.thread.name if arguments.thread is not None else "a thread" @@ -39,3 +46,19 @@ def _escaped(self, arguments: threading.ExceptHookArgs) -> None: def _record(self, message: str) -> None: with self._lock: self._messages.append(message) + + +def _logged_by_the_exit_join() -> bool: + """Whether the record being handled was logged from within the join that awaits background work. + + The application logs through a wrapper, so a record names the wrapper as its origin, and the + call stack is what still holds the join. + """ + frame = inspect.currentframe() + while frame is not None: + if frame.f_code is EXIT_JOIN_CODE: + return True + + frame = frame.f_back + + return False diff --git a/tests/suite/screens/checks.py b/tests/suite/screens/checks.py index 3ec19e996..1b722c72c 100644 --- a/tests/suite/screens/checks.py +++ b/tests/suite/screens/checks.py @@ -1,5 +1,6 @@ +import threading from dataclasses import dataclass -from typing import List, Tuple +from typing import List, Sequence, Tuple from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_WINDOW_MAIN from sampletones_application.utils.gui.modal_queue import ModalQueue, ModalQueueSnapshot @@ -34,7 +35,12 @@ def read_screen_state() -> ScreenState: def quiet_findings(errors: ErrorRecords) -> List[str]: """The errors the application logged or let escape a thread.""" - return [f"The application reported an error: {message}" for message in errors.messages] + return [f"The application reported: {message}" for message in errors.messages] + + +def surviving_thread_findings(threads: Sequence[threading.Thread]) -> List[str]: + """The threads still running once the application has stopped and torn its context down.""" + return [f"The thread '{thread.name}' outlived the application's exit" for thread in threads] def contained_findings( diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index 520ab4fec..f661a49d8 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -1,13 +1,17 @@ import time -from typing import Final, Optional, Sequence +from functools import partial +from typing import Callable, Dict, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge, RenderThreadStoppedError from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.items import Item, read_viewport +from tests.suite.screens.dearpygui.items import Item, read_hovered, read_viewport from tests.suite.screens.dearpygui.keys import ( IMGUI_LEFT_CTRL, IMGUI_LEFT_SHIFT, IMGUI_LETTER_A, + key_name, keysym_of, keysym_of_character, ) @@ -20,7 +24,17 @@ SETTLE_FRAMES: Final[int] = 2 SINGLE_PRESS: Final[int] = 1 DOUBLE_PRESS: Final[int] = 2 +HOVER_ARRIVAL_FRAMES: Final[int] = 5 REACH_TIMEOUT_SECONDS: Final[float] = 10.0 +IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { + MouseButton.LEFT: dpg.mvMouseButton_Left, + MouseButton.RIGHT: dpg.mvMouseButton_Right, + MouseButton.MIDDLE: dpg.mvMouseButton_Middle, +} + + +class GestureLostError(AssertionError): + """Raised when a gesture's input never reached the control it was aimed at.""" class Hand: @@ -31,6 +45,11 @@ class Hand: input once a frame: the pointer rests over a control before pressing it, and a modifier is held a frame before the key it modifies goes down. A gesture that closes the application ends where the application stopped, which is how a scenario presses the button that leaves it. + + A gesture confirms that it arrived. The control reports the pointer resting on it before a + button goes down, and the application reports every held button and named key down while it is + held, so a press lost on the way fails where it was lost, and a scenario expecting nothing to + happen learns that its gesture was made. """ def __init__( @@ -54,9 +73,14 @@ def right_click(self, item: Item) -> None: self._press(item, MouseButton.RIGHT, SINGLE_PRESS) def hover(self, item: Item) -> None: - """Rests the pointer over ``item``.""" + """Rests the pointer over ``item`` until the item reports it. + + Raises: + GestureLostError: If the item never reports the pointer resting on it. + """ self._device.move(self._aim(item)) self._bridge.frames(HOVER_FRAMES) + self._confirm_hover(item) def press_key( self, @@ -65,15 +89,19 @@ def press_key( modifiers: Sequence[int], ) -> None: """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" - held = tuple(keysym_of(modifier) for modifier in modifiers) - for keysym in held: - self._device.key_down(keysym) - if held: + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + if modifiers: self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) - self._tap(keysym_of(key)) - for keysym in reversed(held): - self._device.key_up(keysym) + self._device.key_down(keysym_of(key)) + self._settle(HOLD_FRAMES) + self._confirm_keys([key]) + self._device.key_up(keysym_of(key)) + self._settle(RELEASE_FRAMES) + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) self._settle(SETTLE_FRAMES) @@ -109,9 +137,11 @@ def _press( count: int, ) -> None: self.hover(item) + imgui_button = IMGUI_MOUSE_BUTTONS[button] for _ in range(count): self._device.button_down(button) self._settle(HOLD_FRAMES) + self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), f"The {button.name.lower()} button") self._device.button_up(button) self._settle(RELEASE_FRAMES) @@ -123,6 +153,45 @@ def _tap(self, keysym: int) -> None: self._device.key_up(keysym) self._settle(RELEASE_FRAMES) + def _confirm_hover(self, item: Item) -> None: + """Waits a few frames for ``item`` to report the pointer resting on it, where its kind reports hover. + + Raises: + GestureLostError: If the item never reports the pointer, which is a press bound elsewhere. + """ + for _ in range(HOVER_ARRIVAL_FRAMES): + if self._bridge.ask(lambda: read_hovered(item)) is not False: + return + + self._bridge.frames(ONE_FRAME) + + raise GestureLostError( + f"The pointer was aimed at {item!r} and the item reports no hover: " + f"something covers it, or the region it scrolls in clips it" + ) + + def _confirm_keys(self, keys: Sequence[int]) -> None: + for key in keys: + self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") + + def _confirm( + self, + is_down: Callable[[], bool], + what: str, + ) -> None: + """Checks that the application reads a held button or key as down, unless the gesture closed it. + + Raises: + GestureLostError: If the application reads it as up while it is held. + """ + try: + arrived = self._bridge.ask(is_down) + except RenderThreadStoppedError: + return + + if not arrived: + raise GestureLostError(f"{what} is held on the display and the application reads it as up") + def _settle(self, count: int) -> None: """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" try: diff --git a/tests/suite/screens/dearpygui/isolation.py b/tests/suite/screens/dearpygui/isolation.py index 7794da4fd..776c2f8a2 100644 --- a/tests/suite/screens/dearpygui/isolation.py +++ b/tests/suite/screens/dearpygui/isolation.py @@ -20,6 +20,7 @@ "--no-header", ) OUTPUT_TAIL_CHARACTERS: Final[int] = 20_000 +NODE_SEPARATOR: Final[str] = "::" @dataclass(frozen=True) @@ -71,19 +72,22 @@ def run_isolated( item: pytest.Item, *, environment: Mapping[str, str], + working_directory: Path, report_path: Path, timeout: float, ) -> List[pytest.TestReport]: """Runs ``item`` in a fresh interpreter and returns its reports, as if it had run here. - The child is a pytest run of the item's node id alone, under ``environment``. It writes its - reports to ``report_path``. A phase missing from them, because the child crashed or ran out of - time, comes back as a failure carrying the child's output, and so does the last phase of a child - that crashed after reporting all of them. + The child is a pytest run of the item's node id alone, under ``environment`` and started in + ``working_directory``, which is where the application under test finds itself launched. It + writes its reports to ``report_path``. A phase missing from them, because the child crashed or + ran out of time, comes back as a failure carrying the child's output, and so does the last phase + of a child that crashed after reporting all of them. """ child = _run_child( item, environment=environment, + working_directory=working_directory, timeout=timeout, ) reports = _read_reports(item.config, report_path) @@ -94,13 +98,21 @@ def _run_child( item: pytest.Item, *, environment: Mapping[str, str], + working_directory: Path, timeout: float, ) -> ChildRun: - command = [sys.executable, "-m", "pytest", item.nodeid, *CHILD_ARGUMENTS] + command = [ + sys.executable, + "-m", + "pytest", + _located_nodeid(item), + f"--rootdir={item.config.rootpath}", + *CHILD_ARGUMENTS, + ] try: completed = subprocess.run( command, - cwd=item.config.rootpath, + cwd=working_directory, env=dict(environment), timeout=timeout, capture_output=True, @@ -119,6 +131,12 @@ def _run_child( ) +def _located_nodeid(item: pytest.Item) -> str: + """The item's node id with its file named by an absolute path, which a run started anywhere finds.""" + _, separator, name = item.nodeid.partition(NODE_SEPARATOR) + return f"{item.path}{separator}{name}" + + def _read_reports(config: pytest.Config, path: Path) -> Dict[str, pytest.TestReport]: if not path.exists(): return {} diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py index d8ff56552..bb65a5e8d 100644 --- a/tests/suite/screens/dearpygui/items.py +++ b/tests/suite/screens/dearpygui/items.py @@ -98,6 +98,19 @@ def read_windows() -> Tuple[WindowReading, ...]: return tuple(windows) +def read_hovered(item: Item) -> Optional[bool]: + """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. + + Runs on the render thread. An item covered by a popup, clipped by the region it scrolls in, or + passed over while another item holds the mouse reads as not hovered. + """ + state = dpg.get_item_state(item) + if "hovered" not in state: + return None + + return bool(state["hovered"]) + + def read_label(item: Item) -> str: """The label ``item`` carries. Runs on the render thread.""" return str(dpg.get_item_label(item) or "") diff --git a/tests/suite/screens/dearpygui/keys.py b/tests/suite/screens/dearpygui/keys.py index 24ef16d07..2b37ad463 100644 --- a/tests/suite/screens/dearpygui/keys.py +++ b/tests/suite/screens/dearpygui/keys.py @@ -82,6 +82,11 @@ def keysym_of(key: int) -> int: return XK.string_to_keysym(name) +def key_name(key: int) -> str: + """The name the X keyboard table gives the Dear ImGui key ``key``, for a report a person reads.""" + return KEYSYM_NAMES.get(key, str(key)) + + def keysym_of_character(character: str) -> int: """The X keysym typing ``character`` sends; a printable ASCII keysym is its own code point.""" return ord(character) diff --git a/tests/suite/screens/dearpygui/windows.py b/tests/suite/screens/dearpygui/windows.py new file mode 100644 index 000000000..38afd31d3 --- /dev/null +++ b/tests/suite/screens/dearpygui/windows.py @@ -0,0 +1,69 @@ +from typing import Final, Iterator + +from Xlib import X +from Xlib.display import Display +from Xlib.protocol.event import ClientMessage +from Xlib.xobject.drawable import Window + +PROCESS_PROPERTY: Final[str] = "_NET_WM_PID" +PROTOCOLS_PROPERTY: Final[str] = "WM_PROTOCOLS" +DELETE_PROTOCOL: Final[str] = "WM_DELETE_WINDOW" +CLIENT_MESSAGE_FORMAT: Final[int] = 32 +UNUSED_FIELD: Final[int] = 0 + + +class MissingWindowError(AssertionError): + """Raised when no window on the display belongs to the process a scenario drives.""" + + +class WindowManager: + """What a window manager does to the application's window on a scenario's display. + + The scenario's X server runs no window manager, so the request a manager sends when a user + presses the close button of a title bar is sent from here, to the window carrying the + application's process id. + """ + + def __init__( + self, + display_name: str, + process_id: int, + ) -> None: + self._display = Display(display_name) + self._process_id = process_id + + def request_close(self) -> None: + """Asks the application's window to close, as the close button of its title bar does. + + Raises: + MissingWindowError: If no window on the display carries the application's process id. + """ + window = self._own_window() + message = ClientMessage( + window=window, + client_type=self._display.intern_atom(PROTOCOLS_PROPERTY), + data=( + CLIENT_MESSAGE_FORMAT, + [self._display.intern_atom(DELETE_PROTOCOL), X.CurrentTime, UNUSED_FIELD, UNUSED_FIELD, UNUSED_FIELD], + ), + ) + window.send_event(message, event_mask=X.NoEventMask) + self._display.sync() + + def close(self) -> None: + self._display.close() + + def _own_window(self) -> Window: + process_property = self._display.intern_atom(PROCESS_PROPERTY) + for window in _windows_under(self._display.screen().root): + owner = window.get_full_property(process_property, X.AnyPropertyType) + if owner is not None and list(owner.value) == [self._process_id]: + return window + + raise MissingWindowError(f"No window on the display carries the process id {self._process_id}") + + +def _windows_under(window: Window) -> Iterator[Window]: + for child in window.query_tree().children: + yield child + yield from _windows_under(child) diff --git a/tests/suite/screens/dearpygui/xtest.py b/tests/suite/screens/dearpygui/xtest.py index 61ac6d9c5..a06a9bdb7 100644 --- a/tests/suite/screens/dearpygui/xtest.py +++ b/tests/suite/screens/dearpygui/xtest.py @@ -25,10 +25,14 @@ class XTestDevice: Each call sends one event and flushes it, so the server holds it before the next frame polls. The display is the scenario's own, which keeps every event inside the application under test. + The display repeats no held key, so a key held across a slow frame arrives as the one press a + person made. """ def __init__(self, display_name: str) -> None: self._display = Display(display_name) + self._display.change_keyboard_control(auto_repeat_mode=X.AutoRepeatModeOff) + self._display.sync() def move(self, point: Point) -> None: xtest.fake_input(self._display, X.MotionNotify, x=point.x, y=point.y) diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index 62ae8f386..7a6047863 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -6,9 +6,6 @@ from sampletones_application.application import Application from sampletones_application.config.profile import UserProfile -from sampletones_application.config.session.state.state import ApplicationState -from sampletones_application.config.session.state.window import ViewportState -from sampletones_shared.utils.serialization import save_yaml_atomic from tests.suite.screens.application import NOTHING_TO_OPEN, Boundaries, ScreenApplication, Startup from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs @@ -19,6 +16,7 @@ from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.hosting import host from tests.suite.screens.dearpygui.isolation import ReportRecorder, run_isolated +from tests.suite.screens.dearpygui.windows import WindowManager from tests.suite.screens.dearpygui.xtest import XTestDevice from tests.suite.screens.environment import ( ARTIFACTS_VARIABLE, @@ -31,6 +29,7 @@ from tests.suite.screens.paths import FAILURE_SCREENSHOT, SCREENS_DIRECTORY from tests.suite.screens.render_thread import QueueRenderThread from tests.suite.screens.screen import Screen +from tests.suite.screens.world import World, lived_in_world CHILD_TIMEOUT_SECONDS: Final[float] = 180.0 ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 @@ -76,6 +75,7 @@ def pytest_runtest_protocol( for report in run_isolated( item, environment=environment, + working_directory=folders.home, report_path=folders.reports, timeout=CHILD_TIMEOUT_SECONDS, ): @@ -123,6 +123,12 @@ def hosted(*arguments: object, **keywords: object) -> None: pyfuncitem.obj = original +@pytest.fixture +def world() -> World: + """What the scenario's home holds as the application starts; a scenario overrides it to seed its own.""" + return lived_in_world() + + @pytest.fixture def startup() -> Startup: """The documents the application opens as it starts; a scenario overrides it to open its own.""" @@ -171,6 +177,7 @@ def screen_boundaries( @pytest.fixture def screen_application( + world: World, startup: Startup, screen_render_thread: QueueRenderThread, screen_bridge: Bridge, @@ -178,7 +185,7 @@ def screen_application( ) -> ScreenApplication: """SampleToNES as ``sampletones open`` starts it, on the scenario's display and in its home.""" profile = UserProfile.user() - _seed_viewport(profile.state) + world.write(profile) application = Application( profile=profile, reconstruction_path=startup.reconstruction, @@ -203,15 +210,18 @@ def screen( ) -> Iterator[Screen]: """The application the scenario drives, with a user's hand on its display.""" device = XTestDevice(os.environ["DISPLAY"]) + window_manager = WindowManager(os.environ["DISPLAY"], os.getpid()) yield Screen( bridge=screen_bridge, render_thread=screen_render_thread, hand=Hand(screen_bridge, device), + window_manager=window_manager, language=screen_application.language, shortcuts=screen_application.shortcuts, dialogs=screen_boundaries.dialogs, artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), ) + window_manager.close() device.close() @@ -226,17 +236,3 @@ def _worker_display(config: pytest.Config) -> str: config.stash[DISPLAY_KEY] = display config.stash[DISPLAY_NAME_KEY] = name return name - - -def _seed_viewport(state_path: Path) -> None: - """Writes the session state a first run of the scenario finds: the window filling the display.""" - state = ApplicationState( - viewport=ViewportState( - width=SCREEN_SIZE.width, - height=SCREEN_SIZE.height, - x=0, - y=0, - ) - ) - state_path.parent.mkdir(parents=True, exist_ok=True) - save_yaml_atomic(state_path, state.model_dump(mode="json")) diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 77c91d326..24e457247 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -9,6 +9,7 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.screenshot import capture +from tests.suite.screens.dearpygui.windows import WindowManager from tests.suite.screens.keyboard import press_combination, primary_combination from tests.suite.screens.render_thread import QueueRenderThread from tests.suite.screens.views.display_settings import DisplaySettings @@ -38,6 +39,7 @@ def __init__( bridge: Bridge, render_thread: QueueRenderThread, hand: Hand, + window_manager: WindowManager, language: LanguageManager, shortcuts: ShortcutManager, dialogs: ScriptedFileDialogs, @@ -45,6 +47,7 @@ def __init__( ) -> None: self.bridge = bridge self.hand = hand + self._window_manager = window_manager self._render_thread = render_thread self._shortcuts = shortcuts self._dialogs = dialogs @@ -81,6 +84,10 @@ def is_running(self) -> bool: """Whether the application still draws its window.""" return self._render_thread.is_running() + def close_window(self) -> None: + """Closes the application's window from its title bar, the way a window manager asks it to.""" + self._window_manager.request_close() + def wait_for_exit(self) -> bool: """Waits for the application to stop, and says whether it did within the expectation's time.""" return self._render_thread.wait_stopped(EXPECT_TIMEOUT_SECONDS) diff --git a/tests/suite/screens/steps/__init__.py b/tests/suite/screens/steps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/steps/project.py b/tests/suite/screens/steps/project.py new file mode 100644 index 000000000..da78b12f7 --- /dev/null +++ b/tests/suite/screens/steps/project.py @@ -0,0 +1,41 @@ +import operator +from pathlib import Path + +from sampletones_core.project import ProjectContainer +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen + + +def save_new_project(screen: Screen, path: Path) -> None: + """Starts a new project and saves it to ``path`` through File โ–ธ Save project as.""" + screen.project.create() + save_project_as(screen, path) + + +def save_project_as(screen: Screen, path: Path) -> None: + """Saves the open project to ``path`` through File โ–ธ Save project as, and dismisses the notice.""" + project = screen.project + screen.answer_next_dialog(DialogKind.SAVE, path) + + project.save_as() + + screen.expect(project.saved_notice.is_shown, bool, description="the project saved notice") + project.saved_notice.confirm() + screen.expect(project.saved_notice.is_shown, operator.not_, description="the notice gone") + + +def retitle_project(screen: Screen, title: str) -> None: + """Types ``title`` into Project properties and confirms it, which leaves the project unsaved.""" + properties = screen.project.properties + properties.open() + screen.expect(properties.is_shown, bool, description="the Project properties dialog") + + properties.retitle(title) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + + +def saved_project_title(path: Path) -> str: + """The title the project file at ``path`` carries.""" + return ProjectContainer.load(path).info.title diff --git a/tests/suite/screens/views/prompts.py b/tests/suite/screens/views/prompts.py index dd19ece72..9c510d521 100644 --- a/tests/suite/screens/views/prompts.py +++ b/tests/suite/screens/views/prompts.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_BUTTON, SUF_BUTTON_CANCEL, SUF_BUTTON_OK, SUF_BUTTON_SAVE @@ -30,12 +30,16 @@ def __init__( def window(self) -> Optional[WindowReading]: """The prompt's window while one is shown, and ``None`` otherwise.""" - shown = [ + shown = self.shown_windows() + return shown[0] if shown else None + + def shown_windows(self) -> List[WindowReading]: + """Every window of the prompt's kind standing on the screen, which is one for a question asked once.""" + return [ window for window in self._bridge.ask(read_windows) if window.shown and is_tag_within(window.alias, self._tag) ] - return shown[0] if shown else None def is_shown(self) -> bool: return self.window() is not None diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py new file mode 100644 index 000000000..f267b3004 --- /dev/null +++ b/tests/suite/screens/world.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass +from typing import Optional, Protocol, Tuple + +from sampletones_application.config.managers.application import ApplicationConfigManager +from sampletones_application.config.managers.state import ApplicationStateManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH +from tests.suite.screens.environment import SCREEN_SIZE + + +class HomeFile(Protocol): + """Something a scenario's home holds before the application starts: a recording, a document, a library.""" + + def write(self) -> None: + """Writes it under the home of the scenario's process.""" + + +@dataclass(frozen=True) +class World: + """What a scenario's home holds before the application draws its first frame. + + Each file is written by the same model and the same manager the application reads it with, so + a home seeded here is a home a previous run could have left. + + Attributes: + state: The session the application restores, or ``None`` for a home no run has left one in. + application_config: The application's settings, or ``None`` for a home holding none. + config: The reconstruction settings in the documents folder, or ``None`` for a home holding none. + files: Everything else the home holds. + """ + + state: Optional[ApplicationState] + application_config: Optional[ApplicationConfig] + config: Optional[Config] + files: Tuple[HomeFile, ...] + + def write(self, profile: UserProfile) -> None: + """Lays the world out in the home ``profile`` reads its settings from.""" + if self.state is not None: + state_manager = ApplicationStateManager(profile.state) + state_manager.state = self.state + state_manager.save() + + if self.application_config is not None: + config_manager = ApplicationConfigManager(profile.config) + config_manager.config = self.application_config + config_manager.save() + + if self.config is not None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + self.config.save(CONFIG_PATH) + + for file in self.files: + file.write() + + +def screen_filling_state() -> ApplicationState: + """A session whose window spans the scenario's screen, which the application fits within its margins.""" + return ApplicationState( + viewport=ViewportState( + width=SCREEN_SIZE.width, + height=SCREEN_SIZE.height, + x=0, + y=0, + ) + ) + + +def lived_in_world() -> World: + """The home of a user who ran the application once: a session left behind, and nothing else.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=(), + ) From c62140a97ae0d15842308409823a9a73b8f3b0f7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 12:10:23 +0200 Subject: [PATCH 07/19] Proved: old and broken documents, a fresh home and what a restart keeps, on a live screen --- docs/development/application/screens.md | 19 +- docs/development/bugs-and-todos.md | 10 + .../integration/compatibility/test_project.py | 29 +- tests/screens/application/test_old_files.py | 677 ++++++++++++++++++ tests/screens/application/test_restart.py | 394 ++++++++++ tests/screens/application/test_startup.py | 55 ++ tests/suite/compatibility.py | 35 +- tests/suite/screens/application.py | 6 +- tests/suite/screens/boundaries/dialogs.py | 41 +- tests/suite/screens/boundaries/errors.py | 40 +- tests/suite/screens/checks.py | 12 +- tests/suite/screens/dearpygui/display.py | 7 + tests/suite/screens/dearpygui/geometry.py | 17 +- tests/suite/screens/dearpygui/hand.py | 153 +++- tests/suite/screens/dearpygui/items.py | 178 ++++- tests/suite/screens/dearpygui/reach.py | 17 +- tests/suite/screens/dearpygui/semantic.py | 26 + tests/suite/screens/dearpygui/xtest.py | 2 + tests/suite/screens/plugin.py | 1 + tests/suite/screens/screen.py | 80 ++- tests/suite/screens/seeds.py | 100 +++ tests/suite/screens/views/browsers.py | 103 +++ tests/suite/screens/views/instructions.py | 44 ++ tests/suite/screens/views/main.py | 121 ++++ tests/suite/screens/views/notices.py | 43 ++ tests/suite/screens/views/project.py | 6 + tests/suite/screens/views/reconstructions.py | 45 ++ tests/suite/screens/views/sequencer.py | 66 ++ tests/suite/screens/written.py | 23 + 29 files changed, 2310 insertions(+), 40 deletions(-) create mode 100644 tests/screens/application/test_old_files.py create mode 100644 tests/screens/application/test_restart.py create mode 100644 tests/screens/application/test_startup.py create mode 100644 tests/suite/screens/seeds.py create mode 100644 tests/suite/screens/views/browsers.py create mode 100644 tests/suite/screens/views/instructions.py create mode 100644 tests/suite/screens/views/main.py create mode 100644 tests/suite/screens/views/notices.py create mode 100644 tests/suite/screens/views/reconstructions.py create mode 100644 tests/suite/screens/views/sequencer.py create mode 100644 tests/suite/screens/written.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 97737f7d7..c21f96629 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -37,7 +37,9 @@ draws on an X server of its own, so its scenarios take only its own input. The `world` fixture says what the home holds before the first frame: the session a previous run left, the settings, and the recordings, documents and libraries the scenario works on. Each file is written by the model and the manager the application reads it with, so a seeded home is one a real run could have left. A -scenario class overrides `world` to seed its own. +scenario class overrides `world` to seed its own. The [compatibility corpus](../../../tests/data/compatibility/README.md) +supplies the documents a release wrote, whole or damaged: restated at a version no step reaches, cut +short, or replaced by bytes of another kind. ### 4. A scenario is written to break the application @@ -75,7 +77,8 @@ only where the count is the contract itself. Every scenario is held to the same after-checks. A failing gesture is logged and swallowed so the interface keeps running, which is why the checks read the log as well as the screen. -- **Quiet.** The application logged no error and no thread let an exception escape. +- **Quiet.** The application logged no error and no thread let an exception escape. A scenario that + provokes a failure claims the error it provokes, and an error nobody claims still fails it. - **Contained.** The application started no program, and every file dialog it opened had an answer waiting. - **Settled.** No modal conversation is left open, and every window lies inside the viewport. @@ -108,10 +111,14 @@ queue the application's own workers post to, between two frames ([the render thr ## Gestures A pointer gesture aims at the middle of a control once the control is in reach: it exists, it and every -container around it are shown, it was drawn in the last frame, it answers a press, its box lies inside the -viewport, and no modal window of another tree holds the screen. The control must also stand where it stood a -frame before, since a dialog sized by its content settles its place over its first frames -([dialogs](dialogs.md)). +container around it are shown, it was drawn in the last frame, it and every container around it answer a +press, its box lies inside the viewport, its middle lies in the part the regions around it leave in view, and +no modal window of another tree holds the screen. The control must also stand where it stood a frame before, +since a dialog sized by its content settles its place over its first frames ([dialogs](dialogs.md)). + +A control in a region that scrolls is brought into view the way a person brings it: the wheel turns over a +region that takes the wheel, and the grip of a region's scrollbar is dragged where the region ignores the +wheel. Keys are pressed on the real keyboard, with modifiers held a frame before the key. A scenario names an action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key, diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 23be83262..8b9ad506d 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -155,3 +155,13 @@ currently out of line. An entry leaves when the code meets the contract again. session and asks the audio device manager for its current device, and with no device selected it raises `ValueError: No audio device selected`. The screen scenario in `tests/screens/test_leaving.py` reproduces it as a known failure. +* A project stating a data version no upgrade step reaches is refused by its shape, not by its version. + `ProjectContainer.load` validates the document before `_validate_document` compares the version, so + the user reads a list of validation errors instead of the version mismatch a reconstruction reports. + `tests/integration/compatibility/test_project.py` and the screen scenario in + `tests/screens/application/test_old_files.py` reproduce it as known failures. +* Leaving keeps the last dialog folder a run found deleted. A dialog opens in the nearest folder still + standing, while `state.yaml` goes on naming the deleted one. `tests/screens/application/test_restart.py` + reproduces it as a known failure. +* Leaving keeps a starred file a run found deleted: `config.yaml` goes on naming it among the favorites. + `tests/screens/application/test_restart.py` reproduces it as a known failure. diff --git a/tests/integration/compatibility/test_project.py b/tests/integration/compatibility/test_project.py index e4d34913e..3f48a2836 100644 --- a/tests/integration/compatibility/test_project.py +++ b/tests/integration/compatibility/test_project.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Final, List import pytest @@ -15,7 +16,8 @@ SAMPLETONES_PROJECT_DATA_VERSION, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) -from tests.suite.compatibility import PROJECT_VERSION, archived, stored_version +from sampletones_shared.exceptions.project import IncompatibleProjectVersionError, InvalidProjectDataValuesError +from tests.suite.compatibility import PROJECT_VERSION, archived, restated_document, stored_version VOICE_NAMES: Final[List[str]] = ["kick", "kick again"] PATTERN_NAME: Final[str] = "verse" @@ -27,6 +29,8 @@ PROJECT_TEMPO: Final[int] = 132 ROW_TRANSPOSE: Final[int] = 3 ROW_VOLUME: Final[int] = 9 +UNREACHED_VERSION: Final[str] = "0.9" +UNREACHED_FILENAME: Final[str] = "unreached.stp" @pytest.fixture(name="loaded") @@ -55,6 +59,29 @@ def test_it_opens_all_the_same(self, loaded: Project) -> None: assert loaded.voices +class TestAVersionNoStepReaches: + """A document stating a version no chain reaches is refused by the load contract, which names both versions.""" + + @pytest.mark.xfail( + strict=True, + raises=InvalidProjectDataValuesError, + reason="bugs-and-todos ยง Bugs: a project at a version no step reaches is refused by its shape", + ) + def test_a_load_refuses_it_naming_both_versions(self, tmp_path: Path) -> None: + path = tmp_path / UNREACHED_FILENAME + path.write_bytes( + restated_document(archived(ObjectKind.PROJECT, PROJECT_VERSION), ObjectKind.PROJECT, UNREACHED_VERSION) + ) + + with pytest.raises(IncompatibleProjectVersionError) as refused: + ProjectContainer.load(path) + + assert (refused.value.actual_version, refused.value.expected_version) == ( + UNREACHED_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + ) + + class TestTheVoicesAnUpgradedProjectHolds: """What a 1.0 document called its samples are the voices a project holds now.""" diff --git a/tests/screens/application/test_old_files.py b/tests/screens/application/test_old_files.py new file mode 100644 index 000000000..6bb4b59c7 --- /dev/null +++ b/tests/screens/application/test_old_files.py @@ -0,0 +1,677 @@ +import json +import operator +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import Final, List, Optional, Tuple, Type + +import pytest +import yaml + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.playback import PlaybackConfig +from sampletones_application.config.session.state.current import Current +from sampletones_application.paths import APPLICATION_STATE_PATH +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.application import ( + SAMPLETONES_LIBRARY_DATA_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + SAMPLETONES_RECONSTRUCTION_DATA_VERSION, +) +from sampletones_shared.exceptions.reconstruction import ( + IncompatibleReconstructionVersionError, + InvalidReconstructionValuesError, +) +from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document, stored_version +from tests.suite.screens.application import NOTHING_TO_OPEN, Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Damage, Recording, WrittenBytes, archived_document, damaged_document +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.world import HomeFile, World, screen_filling_state + +ARCHIVED_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Archived.stn" +SECOND_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +ARCHIVED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Archived.stp" +SECOND_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Second.stp" +VANISHED: Final[str] = "vanished" +OLDER_RECONSTRUCTION_VERSION: Final[str] = "2.0" +OLDER_PROJECT_VERSION: Final[str] = "0.9" +RECORDING_SECONDS: Final[float] = 0.5 +RECORDING_FREQUENCY: Final[float] = 220.0 +AUDIO_PATH_FIELD: Final[str] = "audio_filepath" +SAMPLES_FIELD: Final[str] = "samples" +NAME_FIELD: Final[str] = "name" +CONFIG_FIELD: Final[str] = "config" +INVALID_VALUES: Final[str] = "reconstructions.browser.message.invalid_values" +INCOMPATIBLE_VERSION: Final[str] = "reconstructions.browser.template.incompatible_version_template" +RECONSTRUCTION_NOT_FOUND: Final[str] = "reconstructions.browser.message.file_not_found" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" + + +def future_version(current: str) -> str: + """A data version past the one this build writes, which no build has written yet.""" + major, _ = current.split(".", 1) + return f"{int(major) + 1}.0" + + +def stored_recording() -> Recording: + """The recording the archived reconstruction names, laid where its relative path leads from the home. + + The application runs in the home, so a relative path the document names resolves there. + """ + document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) + return Recording( + destination=Path.cwd() / document[AUDIO_PATH_FIELD], + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + + +def stored_voice_names() -> List[str]: + """The voice names the archived project stores, read off its document without the application's model.""" + document = stored_document(archived(ObjectKind.PROJECT, ARCHIVED_VERSIONS[ObjectKind.PROJECT])) + return [sample[NAME_FIELD] for sample in document[SAMPLES_FIELD]] + + +def damaged_name(damage: Damage, suffix: str) -> str: + return f"{damage.name.lower()}{suffix}" + + +def damaged_reconstruction(damage: Damage) -> HomeFile: + return damaged_document( + ObjectKind.RECONSTRUCTION, + damage, + destination=RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, ".stn"), + older_version=OLDER_RECONSTRUCTION_VERSION, + future_version=future_version(SAMPLETONES_RECONSTRUCTION_DATA_VERSION), + ) + + +def damaged_project(damage: Damage) -> HomeFile: + return damaged_document( + ObjectKind.PROJECT, + damage, + destination=PROJECTS_DIRECTORY / damaged_name(damage, ".stp"), + older_version=OLDER_PROJECT_VERSION, + future_version=future_version(SAMPLETONES_PROJECT_DATA_VERSION), + ) + + +def stated_version(damage: Damage, current: str, older: str) -> str: + return older if damage is Damage.OLDER_VERSION else future_version(current) + + +def world_of(*files: HomeFile) -> World: + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=files, + ) + + +def reconstruction_refusal(screen: Screen, damage: Damage) -> str: + """What the application says refusing a reconstruction with ``damage``, as the language file words it.""" + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + stated = stated_version(damage, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, OLDER_RECONSTRUCTION_VERSION) + return screen.words(INCOMPATIBLE_VERSION).format(stated, SAMPLETONES_RECONSTRUCTION_DATA_VERSION) + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return screen.words(INVALID_VALUES) + + +def reconstruction_failure(damage: Optional[Damage]) -> Type[BaseException]: + """The failure the application records for a reconstruction with ``damage``, or for a file gone.""" + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + return IncompatibleReconstructionVersionError + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return InvalidReconstructionValuesError + case None: + return FileNotFoundError + + +def shown_notice(screen: Screen) -> Notice: + notices = (screen.error_notice, screen.file_not_found_notice) + screen.expect(lambda: any(notice.is_shown() for notice in notices), bool, description="a notice") + return next(notice for notice in notices if notice.is_shown()) + + +class TestTheLastReleasesReconstruction: + """A reconstruction the last release wrote opens in this build, its recording found where it names it.""" + + @pytest.fixture + def world(self) -> World: + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + stored_recording(), + ) + + def test_it_opens_from_the_browser(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: reconstructions.browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + reconstructions.browser.open_by_click(heading) + row = screen.expect_item( + lambda: reconstructions.browser.file_row(ARCHIVED_RECONSTRUCTION), description="its row" + ) + + reconstructions.browser.double_click(row) + + screen.expect( + lambda: reconstructions.shows_open(ARCHIVED_RECONSTRUCTION), + bool, + description="the archived reconstruction open", + ) + assert not screen.file_not_found_notice.is_shown() + assert not screen.error_notice.is_shown() + + +class TestTheLastReleasesProject: + """A project the last release wrote opens in this build, lists its voices and plays its song.""" + + @pytest.fixture + def world(self) -> World: + looping = ApplicationConfig(playback=PlaybackConfig(loop_song=True)) + return World( + state=screen_filling_state(), + application_config=looping, + config=None, + files=(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT),), + ) + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_it_opens_at_start_lists_its_voices_and_plays_its_song(self, screen: Screen) -> None: + sequencer = screen.sequencer + playing_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PAUSE)) + stopped_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PLAY)) + + def lists_its_voices(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + def plays(screen: Screen) -> None: + assert sequencer.playback.play_entry() == stopped_entry + + sequencer.playback.play() + + screen.expect(sequencer.playback.play_entry, playing_entry.__eq__, description="Play reading Pause") + assert sequencer.playback.can_stop() + + def stops(screen: Screen) -> None: + sequencer.playback.stop() + + screen.expect(sequencer.playback.play_entry, stopped_entry.__eq__, description="Pause reading Play") + assert not sequencer.playback.can_stop() + + screen.scenario(lists_its_voices, plays, stops).run() + + +class TestOpeningTheLastReleasesProjectFromTheMenu: + """File โ–ธ Open project opens a project the last release wrote.""" + + @pytest.fixture + def world(self) -> World: + return world_of(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT)) + + def test_it_lists_the_stored_voices(self, screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.OPEN, ARCHIVED_PROJECT) + + screen.project.open() + + screen.expect(screen.sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + +def _menu_key(element: MenuElements) -> Tuple[Page, Panel, TextType, MenuElements]: + return (Page.GLOBAL, Panel.MENU, TextType.LABEL, element) + + +DOOR_SUFFIX_RECONSTRUCTION: Final[str] = ".stn" +DOOR_SUFFIX_PROJECT: Final[str] = ".stp" + + +def broken_reconstruction_world() -> World: + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, SECOND_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn"), + stored_recording(), + *(damaged_reconstruction(damage) for damage in Damage), + ) + + +def refused_reconstruction( + screen: Screen, + damage: Optional[Damage], + expected: str, +) -> None: + """Reads the notice refusing a reconstruction, dismisses it, and checks the reconstruction open before stands.""" + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(reconstruction_failure(damage).__name__) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert words.startswith(expected), words + assert screen.reconstructions.shows_open(ARCHIVED_RECONSTRUCTION) + + +class TestRefusingABrokenReconstruction: + """A reconstruction that cannot be read is refused with a readable message, and the one open stays open. + + Each door that opens a reconstruction is walked with every kind of damage, and then with a file + removed after it was listed or chosen. The last gesture opens a sound reconstruction the same + way, which shows the door still opens what it should. + """ + + @pytest.fixture + def world(self) -> World: + return broken_reconstruction_world() + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=ARCHIVED_RECONSTRUCTION, project=None) + + def test_through_the_browser(self, screen: Screen) -> None: + browser = screen.reconstructions.browser + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def open_the_listing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + browser.open_by_click(heading) + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + row = screen.expect_item(partial(browser.file_row, path), description="its row") + + browser.double_click(row) + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_listed(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(vanished), description="its row") + vanished.unlink() + + browser.double_click(row) + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(SECOND_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect( + lambda: screen.reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + open_the_listing, + refuse_each_damage, + refuse_a_file_removed_after_it_was_listed, + open_a_sound_one_the_same_way, + ).run() + + def test_through_reconstruction_open(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + screen.answer_next_dialog(DialogKind.OPEN, path) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + vanished.unlink() + screen.answer_next_dialog(DialogKind.OPEN, vanished) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_a_sound_one_the_same_way, + ).run() + + +@dataclass(frozen=True) +class StartupCase: + """A broken document handed to the application as it starts: the damage, or its file gone.""" + + damage: Optional[Damage] + + def name(self, suffix: str) -> str: + return damaged_name(self.damage, suffix) if self.damage is not None else f"{VANISHED}{suffix}" + + +STARTUP_CASES: Final[Tuple[StartupCase, ...]] = (*(StartupCase(damage) for damage in Damage), StartupCase(None)) + + +class TestABrokenReconstructionGivenAtStart: + """A reconstruction handed over at start that cannot be read is set aside quietly, as the restore promises. + + The application starts with nothing open and says nothing; Reconstruction โ–ธ Open then opens a + sound reconstruction, which shows the start left the application as usable as ever. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + return broken_reconstruction_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + return Startup(reconstruction=RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION), project=None) + + def test_it_starts_with_nothing_open(self, screen: Screen, case: StartupCase) -> None: + if case.damage is None: + (RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION)).unlink(missing_ok=True) + reconstructions = screen.reconstructions + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + assert screen.shown_windows() == () + + +ARCHIVE_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.TRUNCATED, Damage.FOREIGN_BYTES) +VERSION_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.OLDER_VERSION, Damage.FUTURE_VERSION) + + +def broken_project_world() -> World: + return world_of( + archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT), + archived_document(ObjectKind.PROJECT, SECOND_PROJECT), + *(damaged_project(damage) for damage in Damage), + ) + + +def open_project(screen: Screen, path: Path) -> None: + """Chooses File โ–ธ Open project with ``path`` and lets the open project be replaced.""" + project = screen.project + screen.answer_next_dialog(DialogKind.OPEN, path) + project.open() + screen.expect(project.replace_prompt.is_shown, bool, description="the question about replacing the project") + project.replace_prompt.confirm() + + +def refused_project(screen: Screen, path: Path) -> str: + """Reads the notice refusing ``path``, dismisses it, checks the project open before stands, and returns its words.""" + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(path.name) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert ARCHIVED_PROJECT.stem in screen.title() + assert screen.sequencer.voices.names() == stored_voice_names() + return words + + +def open_the_second_project_the_same_way(screen: Screen) -> None: + open_project(screen, SECOND_PROJECT) + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + + +class TestRefusingABrokenProject: + """A project that cannot be read is refused with a message naming why, and the one open stays open.""" + + @pytest.fixture + def world(self) -> World: + return broken_project_world() + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_a_damaged_or_missing_archive_is_refused_naming_the_file(self, screen: Screen) -> None: + vanished = PROJECTS_DIRECTORY / f"{VANISHED}.stp" + + def refuse_each_damage(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in ARCHIVE_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + assert path.name in words + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + open_project(screen, vanished) + + words = refused_project(screen, vanished) + + assert vanished.name in words + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_the_second_project_the_same_way, + ).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a project at a version no step reaches is refused by its shape", + ) + def test_a_version_no_step_reaches_is_refused_for_its_version(self, screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in VERSION_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + stated = stated_version(damage, SAMPLETONES_PROJECT_DATA_VERSION, OLDER_PROJECT_VERSION) + assert stated in words and SAMPLETONES_PROJECT_DATA_VERSION in words, words + + +class TestABrokenProjectGivenAtStart: + """A project handed over at start that cannot be read is set aside quietly, as the restore promises.""" + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + return broken_project_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + return Startup(reconstruction=None, project=PROJECTS_DIRECTORY / case.name(DOOR_SUFFIX_PROJECT)) + + def test_it_starts_with_no_project_open(self, screen: Screen, case: StartupCase) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + assert case.name(DOOR_SUFFIX_PROJECT) not in screen.title() + screen.answer_next_dialog(DialogKind.OPEN, SECOND_PROJECT) + + screen.project.open() + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + assert screen.shown_windows() == () + + +OUTDATED_ROW: Final[str] = "instructions.library.template.library_node_outdated_template" +LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" +REBUILD_TIMEOUT_SECONDS: Final[float] = 300.0 + + +def archived_library_path() -> Path: + """Where the archived library lies in the library folder, under the name its settings give a library file.""" + document = stored_document(archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY])) + config = Config(library=InstructionsLibraryConfig.model_validate(document[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +class TestTheLastReleasesLibrary: + """A library the last release built reads as out of date, and only a rebuild the user asks for replaces it.""" + + @pytest.fixture + def world(self) -> World: + return world_of(archived_document(ObjectKind.LIBRARY, archived_library_path())) + + def test_cancel_leaves_it_and_rebuild_rebuilds_it_in_place(self, screen: Screen) -> None: + library = screen.instructions.library + path = archived_library_path() + archived_bytes = path.read_bytes() + outdated_mark = screen.words(OUTDATED_ROW).format("") + + def library_row() -> Item: + return screen.expect_item(lambda: library.row(path.name), description="the library's row") + + def reads_out_of_date(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + assert library.tree.label(library_row()).startswith(outdated_mark) + + def cancel_leaves_it(screen: Screen) -> None: + prompt = library.rebuild_prompt + library.tree.open_by_click(library_row()) + screen.expect(prompt.is_shown, bool, description="the rebuild question") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + row = library_row() + if library.tree.is_open(row): + library.tree.open_by_click(row) + screen.expect(lambda: library.tree.is_open(row), operator.not_, description="the row closed") + library.tree.open_by_click(row) + screen.expect(prompt.is_shown, bool, description="the rebuild question asked at the next opening") + assert path.read_bytes() == archived_bytes + + def rebuild_replaces_it_in_place(screen: Screen) -> None: + name = library.tree.label(library_row()).removeprefix(outdated_mark) + + library.rebuild_prompt.confirm() + + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the rebuilt library loaded", + timeout=REBUILD_TIMEOUT_SECONDS, + ) + assert library.tree.label(library_row()) == name + assert stored_version(ObjectKind.LIBRARY, path) == SAMPLETONES_LIBRARY_DATA_VERSION + assert sorted(LIBRARY_DIRECTORY.glob(f"*{path.suffix}")) == [path] + + screen.scenario(reads_out_of_date, cancel_leaves_it, rebuild_replaces_it_in_place).run() + + +UNKNOWN_CARD: Final[str] = "main.retired.panel" +GONE_FOLDER: Final[str] = "Gone" +LIBRARIES_FOLDER: Final[str] = "Libraries" +VIEWPORT_FIELD: Final[str] = "viewport" +WIDTH_FIELD: Final[str] = "width" +WRONG_KIND_OF_WIDTH: Final[str] = "wide" +GENERAL_FIELD: Final[str] = "general" +MISSING_SETTING: Final[str] = "max_workers" + + +def state_from_elsewhere() -> bytes: + """A session another build left: it names a card and a folder this home lacks, and a width in words.""" + state = screen_filling_state().model_copy( + update={ + "advanced_settings": True, + "current": Current(tab=Tab.SEQUENCER), + "collapsed_cards": {UNKNOWN_CARD: True}, + "expanded_directories": [Path.cwd() / GONE_FOLDER], + } + ) + document = state.model_dump(mode="json") + document[VIEWPORT_FIELD][WIDTH_FIELD] = WRONG_KIND_OF_WIDTH + return yaml.safe_dump(document).encode() + + +def config_from_elsewhere() -> bytes: + """Reconstruction settings another build left: they name a library folder and lack a setting.""" + config = Config() + general = config.general.model_copy(update={"library_directory": str(Path.cwd() / LIBRARIES_FOLDER)}) + document = config.model_copy(update={"general": general}).model_dump(mode="json") + del document[GENERAL_FIELD][MISSING_SETTING] + return json.dumps(document).encode() + + +class TestSessionFilesFromElsewhere: + """A session naming what this home lacks, with a setting of the wrong kind, beside settings missing one. + + The application starts without a word, and every setting the files name in a form this build + reads applies. + """ + + @pytest.fixture + def world(self) -> World: + return World( + state=None, + application_config=None, + config=None, + files=( + WrittenBytes(APPLICATION_STATE_PATH, state_from_elsewhere()), + WrittenBytes(CONFIG_PATH, config_from_elsewhere()), + Recording(Path.cwd() / LIBRARIES_FOLDER / "kick.wav", RECORDING_SECONDS, RECORDING_FREQUENCY), + ), + ) + + def test_it_starts_without_a_word_and_applies_what_it_reads(self, screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + + assert screen.shown_windows() == () + assert screen.main.advanced.is_shown() + assert screen.main.library_directory() == str(Path.cwd() / LIBRARIES_FOLDER) diff --git a/tests/screens/application/test_restart.py b/tests/screens/application/test_restart.py new file mode 100644 index 000000000..c6fd9f868 --- /dev/null +++ b/tests/screens/application/test_restart.py @@ -0,0 +1,394 @@ +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.converter import ConverterConfig +from sampletones_application.config.session.application.favorites import Favorites +from sampletones_application.config.session.state.current import Current +from sampletones_application.config.session.state.paths import LastPaths +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.world import HomeFile, World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_config, written_state + +RECORDINGS_FOLDER: Final[str] = "Recordings" +UNTOUCHED_FOLDER: Final[str] = "Untouched" +LIBRARIES_FOLDER: Final[str] = "Libraries" +RECORDING_NAME: Final[str] = "kick.wav" +RECORDING_SECONDS: Final[float] = 0.25 +RECORDING_FREQUENCY: Final[float] = 220.0 +CHOSEN_CHANNELS_AT_ONCE: Final[int] = 2 +SEEDED_CHANNELS_AT_ONCE: Final[int] = 3 + + +def home_folder(name: str) -> Path: + """A folder of the home the application was started in.""" + return Path.cwd() / name + + +def recording_in(folder: str) -> HomeFile: + return Recording( + destination=home_folder(folder) / RECORDING_NAME, + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + + +def leave(screen: Screen) -> None: + """Leaves through Exit, which writes the session, and waits for the application to stop.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +def world_with(state: ApplicationState, *files: HomeFile) -> World: + return World( + state=state, + application_config=None, + config=None, + files=files, + ) + + +def state_with_advanced(shown: bool) -> ApplicationState: + return screen_filling_state().model_copy(update={"advanced_settings": shown}) + + +class TestTheAdvancedCardAcrossARestart: + """Whether Advanced settings stands is written as the application leaves, and read as it starts.""" + + @pytest.fixture(params=[False, True], ids=["hidden", "shown"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + shown: bool = request.param + return shown + + @pytest.fixture + def world(self, seeded: bool) -> World: + return world_with(state_with_advanced(seeded)) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + screen.expect(screen.main.advanced.is_shown, seeded.__eq__, description="the card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, seeded.__ne__, description="the card toggled") + + leave(screen) + + assert written_state().advanced_settings is not seeded + + +class TestACollapsedCardAcrossARestart: + """A card folded into its bar is written as the application leaves, and stands folded at the next start.""" + + @pytest.fixture(params=[False, True], ids=["open", "collapsed"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + collapsed: bool = request.param + return collapsed + + @pytest.fixture + def world(self, seeded: bool) -> World: + state = screen_filling_state().model_copy(update={"collapsed_cards": {TAG_MAIN_CONVERTER_PANEL: seeded}}) + return world_with(state) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + card.toggle() + + screen.expect(card.is_collapsed, seeded.__ne__, description="the Converter card toggled") + leave(screen) + assert written_state().collapsed_cards.get(TAG_MAIN_CONVERTER_PANEL) is not seeded + + +def explorer_world(opened: List[Path]) -> World: + state = screen_filling_state().model_copy(update={"expanded_directories": opened}) + return world_with(state, recording_in(RECORDINGS_FOLDER), recording_in(UNTOUCHED_FOLDER)) + + +def expect_row(screen: Screen, folder: Path) -> Item: + return screen.expect_item( + lambda: screen.explorer.file_row(folder), + description=f"the row of {folder.name}", + ) + + +class TestLeavingWritesAFolderOpenedInTheExplorer: + """A folder opened in the explorer is written as the application leaves; a sibling left closed is not.""" + + @pytest.fixture + def world(self) -> World: + return explorer_world([]) + + def test_the_session_names_the_opened_folder_alone(self, screen: Screen) -> None: + explorer = screen.explorer + opened = home_folder(RECORDINGS_FOLDER) + row = expect_row(screen, opened) + assert not explorer.is_open(row) + + explorer.double_click(row) + + screen.expect(lambda: explorer.is_open(row), bool, description="the folder open") + leave(screen) + written = written_state().expanded_directories + assert opened in written + assert home_folder(UNTOUCHED_FOLDER) not in written + + +class TestAHomeHoldingAnOpenedFolderShowsIt: + """A folder the session names as opened stands open at the start; a sibling it leaves out stands closed.""" + + @pytest.fixture + def world(self) -> World: + return explorer_world([home_folder(RECORDINGS_FOLDER)]) + + def test_the_named_folder_stands_open_alone(self, screen: Screen) -> None: + explorer = screen.explorer + opened = expect_row(screen, home_folder(RECORDINGS_FOLDER)) + closed = expect_row(screen, home_folder(UNTOUCHED_FOLDER)) + + screen.expect(lambda: explorer.is_open(opened), bool, description="the named folder open") + assert not explorer.is_open(closed) + + +class TestTheTabInFrontAcrossARestart: + """The tab in front is written as the application leaves, and brought forward at the next start.""" + + @pytest.fixture + def world(self) -> World: + state = screen_filling_state().model_copy(update={"current": Current(tab=Tab.INSTRUCTIONS)}) + return world_with(state) + + def test_a_home_holding_it_shows_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.INSTRUCTIONS.__eq__, description="the Instructions tab in front") + + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + leave(screen) + assert written_state().current.tab is Tab.SEQUENCER + + +def converter_config(output: OutputKind, channels_at_once: int) -> ConverterConfig: + settings = StemSettings.covering(list(DEFAULT_CHANNELS)).model_copy(update={"channel_cap": channels_at_once}) + return ConverterConfig(output=output, settings=settings) + + +class TestTheConverterSettingsAcrossARestart: + """The converter's output and its channels at once are written as the application leaves, and stand at the next start.""" + + @pytest.fixture + def world(self) -> World: + return World( + state=screen_filling_state(), + application_config=ApplicationConfig( + converter=converter_config(OutputKind.MIXED, SEEDED_CHANNELS_AT_ONCE), + ), + config=None, + files=(), + ) + + def test_a_home_holding_them_shows_them_and_leaving_writes_the_next(self, screen: Screen) -> None: + main = screen.main + + def shows_the_seeded_ones(screen: Screen) -> None: + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the output the settings name") + assert main.lit_channels_at_once() == SEEDED_CHANNELS_AT_ONCE + + def changes_them(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + main.choose_channels_at_once(CHOSEN_CHANNELS_AT_ONCE) + + screen.expect(main.lit_channels_at_once, CHOSEN_CHANNELS_AT_ONCE.__eq__, description="the step lit") + assert main.output() is OutputKind.PER_RECORDING + + def leaving_writes_them(screen: Screen) -> None: + leave(screen) + + converter = written_application_config().converter + assert converter.output is OutputKind.PER_RECORDING + assert converter.settings.channel_cap == CHOSEN_CHANNELS_AT_ONCE + + screen.scenario(shows_the_seeded_ones, changes_them, leaving_writes_them).run() + + +class TestTheLibraryFolderAcrossARestart: + """The library folder Advanced settings points at is written as the application leaves, and named at the next start.""" + + @pytest.fixture + def world(self) -> World: + config = Config() + general = config.general.model_copy(update={"library_directory": str(home_folder(LIBRARIES_FOLDER))}) + return World( + state=state_with_advanced(True), + application_config=None, + config=config.model_copy(update={"general": general}), + files=(recording_in(LIBRARIES_FOLDER), recording_in(RECORDINGS_FOLDER)), + ) + + def test_a_home_holding_it_names_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + main = screen.main + chosen = home_folder(RECORDINGS_FOLDER) + + def names_the_seeded_one(screen: Screen) -> None: + screen.expect( + main.library_directory, + str(home_folder(LIBRARIES_FOLDER)).__eq__, + description="the library folder the settings name", + ) + + def points_at_another(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.DIRECTORY, chosen) + + main.choose_library_directory() + + screen.expect(main.library_directory, str(chosen).__eq__, description="the chosen folder named") + + def leaving_writes_it(screen: Screen) -> None: + leave(screen) + + assert Path(written_config().general.library_directory) == chosen + + screen.scenario(names_the_seeded_one, points_at_another, leaving_writes_it).run() + + +class TestADiscardedDisplayChange: + """A Display settings change thrown away is never written, while one confirmed is.""" + + def test_leaving_writes_only_the_confirmed_change(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def discard_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def confirm_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + assert settings.vsync() == original[0] + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def leaving_writes_the_confirmed_one(screen: Screen) -> None: + leave(screen) + + assert written_application_config().display.vsync is not original[0] + + screen.scenario(discard_a_change, confirm_a_change, leaving_writes_the_confirmed_one).run() + + +DELETED_FOLDER: Final[str] = "Deleted" +DELETED_SUBFOLDER: Final[str] = "Deeper" +DELETED_RECONSTRUCTION: Final[str] = "Deleted.stn" +DELETED_FAVORITE: Final[str] = "deleted.wav" + + +def deleted_folder() -> Path: + return home_folder(DELETED_FOLDER) / DELETED_SUBFOLDER + + +def deleted_reconstruction() -> Path: + return RECONSTRUCTIONS_DIRECTORY / DELETED_RECONSTRUCTION + + +def deleted_favorite() -> Path: + return home_folder(DELETED_FAVORITE) + + +class TestASessionNamingDeletedFiles: + """A session naming a folder, a reconstruction and a starred file deleted since the last run. + + The application starts with nothing restored and without a word, a dialog opens in the nearest + folder still standing, and leaving writes none of the three back. + """ + + @pytest.fixture + def world(self) -> World: + state = screen_filling_state().model_copy( + update={ + "current": Current(reconstruction=deleted_reconstruction()), + "last_paths": LastPaths(reconstruction=deleted_folder()), + } + ) + return World( + state=state, + application_config=ApplicationConfig(favorites=Favorites(paths={deleted_favorite()})), + config=None, + files=(), + ) + + def test_it_restores_nothing_and_asks_in_the_nearest_standing_folder(self, screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, None) + + screen.reconstructions.open_from_menu() + + request = screen.expect( + screen.dialog_requests, + lambda requests: len(requests) == 1, + description="the open dialog", + )[0] + assert request.initial_directory == Path.cwd() + assert screen.shown_windows() == () + assert screen.reconstructions.open_file() == "" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: leaving keeps the last dialog folder a run found deleted", + ) + def test_the_session_left_names_neither_the_folder_nor_the_reconstruction(self, screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + state = written_state() + assert state.current.reconstruction is None + assert state.last_paths.reconstruction != deleted_folder() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: leaving keeps a starred file a run found deleted", + ) + def test_the_settings_left_name_no_deleted_starred_file(self, screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + assert deleted_favorite() not in written_application_config().favorites.paths diff --git a/tests/screens/application/test_startup.py b/tests/screens/application/test_startup.py new file mode 100644 index 000000000..19599305d --- /dev/null +++ b/tests/screens/application/test_startup.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.screen import Screen +from tests.suite.screens.world import World +from tests.suite.screens.written import written_application_config, written_config, written_state + + +class TestAFreshHome: + """The application starts in a home no run has used, and leaves behind what the next start reads.""" + + @pytest.fixture + def world(self) -> World: + return World( + state=None, + application_config=None, + config=None, + files=(), + ) + + def test_it_opens_on_the_folder_it_was_started_in_and_leaves_its_settings(self, screen: Screen) -> None: + explorer = screen.explorer + + def opens_on_its_folder(screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + home = screen.expect_item( + lambda: explorer.file_row(Path.cwd()), + description="the folder the application was started in", + ) + + assert explorer.is_open(home) + assert screen.shown_windows() == () + + def lists_the_folders_it_keeps_its_documents_in(screen: Screen) -> None: + for folder in (LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY): + assert explorer.file_row(folder) is not None, folder + + def leaves_its_settings_behind(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_state().current.tab is Tab.MAIN + assert Path(written_config().general.library_directory) == LIBRARY_DIRECTORY + assert written_application_config().metadata.version is not None + + screen.scenario( + opens_on_its_folder, + lists_the_folders_it_keeps_its_documents_in, + leaves_its_settings_behind, + ).run() diff --git a/tests/suite/compatibility.py b/tests/suite/compatibility.py index 7c50946b9..d64ae76f3 100644 --- a/tests/suite/compatibility.py +++ b/tests/suite/compatibility.py @@ -1,3 +1,4 @@ +import io import json import zipfile from pathlib import Path @@ -6,6 +7,7 @@ import msgpack from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.data.document import decompress_document from sampletones_tools.compatibility.paths import CORPUS_DIRECTORY, archived_path RECONSTRUCTION_VERSION: Final[str] = "2.1" @@ -30,17 +32,18 @@ def archived(kind: ObjectKind, version: str) -> Path: def stored_document(path: Path) -> Dict[str, Any]: - """What one archived file holds, read without a model so the version can be read first. + """What one stored file holds, read without a model so the version can be read first. A project keeps its document inside an archive, while a reconstruction and a library are the - payload itself, so this answers with the mapping whichever of the two the file is. + payload itself, deflated by the builds that frame their documents, so this answers with the + mapping whichever of these the file is. """ if path.suffix == ".stp": with zipfile.ZipFile(path) as archive: document: Dict[str, Any] = json.loads(archive.read(PROJECT_DOCUMENT)) return document - payload: Dict[str, Any] = msgpack.unpackb(path.read_bytes(), raw=False) + payload: Dict[str, Any] = msgpack.unpackb(decompress_document(path.read_bytes()), raw=False) return payload @@ -62,3 +65,29 @@ def writing_release(path: Path) -> Optional[str]: metadata = document.get(METADATA_FIELD, {}) release = metadata.get(VERSION_FIELD) return release if isinstance(release, str) else None + + +def restated_document(path: Path, kind: ObjectKind, version: str) -> bytes: + """The bytes of the archived file at ``path`` with its stored data version replaced by ``version``. + + Everything else stays as the writing build left it, so a load refusing the result refuses it + for its version alone. + """ + document = stored_document(path) + if kind is ObjectKind.PROJECT: + document[FORMAT_VERSION_FIELD] = version + return _rearchived(path, json.dumps(document).encode()) + + document[METADATA_FIELD][f"{kind.value}_data_version"] = version + packed: bytes = msgpack.packb(document, use_bin_type=True) + return packed + + +def _rearchived(path: Path, project_document: bytes) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(path) as source, zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target: + for member in source.infolist(): + content = project_document if member.filename == PROJECT_DOCUMENT else source.read(member) + target.writestr(member, content) + + return buffer.getvalue() diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application.py index 9540b05b8..71924b9bc 100644 --- a/tests/suite/screens/application.py +++ b/tests/suite/screens/application.py @@ -117,7 +117,7 @@ def finish(self, *, failed: bool) -> None: raise AfterCheckError("\n".join(findings)) def close(self) -> None: - findings = quiet_findings(self._boundaries.errors)[self._reported_errors :] + findings = quiet_findings(self._boundaries.errors, self._reported_errors, None) findings.extend(surviving_thread_findings(_application_threads())) if not self._state_path.exists(): findings.append(f"The application stopped without writing its session state to {self._state_path}") @@ -126,8 +126,8 @@ def close(self) -> None: raise AfterCheckError("\n".join(findings)) def _boundary_findings(self) -> List[str]: - quiet = quiet_findings(self._boundaries.errors) - self._reported_errors = len(quiet) + self._reported_errors = self._boundaries.errors.count + quiet = quiet_findings(self._boundaries.errors, 0, self._reported_errors) return [ *quiet, *contained_findings(self._boundaries.spawns, self._boundaries.dialogs), diff --git a/tests/suite/screens/boundaries/dialogs.py b/tests/suite/screens/boundaries/dialogs.py index 545e3f04f..31a50b0ca 100644 --- a/tests/suite/screens/boundaries/dialogs.py +++ b/tests/suite/screens/boundaries/dialogs.py @@ -25,12 +25,18 @@ class DialogRequest: Attributes: kind: What the dialog asked for. title: The title the dialog carried. + initial_directory: The folder the dialog opened in, if the application named one. + suggested_name: The file name a save dialog offered, if any. + filters: The kinds of file the dialog offered to show. answered: Whether the scenario had queued an answer for it. answer: The path the dialog answered with, or ``None`` for a dismissed dialog. """ kind: DialogKind title: str + initial_directory: Optional[Path] + suggested_name: Optional[str] + filters: Tuple[FileFilter, ...] answered: bool answer: Optional[Path] @@ -77,8 +83,13 @@ def open_file( initial_directory: Optional[Path], filters: Tuple[FileFilter, ...], ) -> Optional[Path]: - del initial_directory, filters - return self._take(DialogKind.OPEN, title) + return self._take( + DialogKind.OPEN, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=filters, + ) def save_file( self, @@ -88,8 +99,14 @@ def save_file( suggested_name: Optional[str], filters: Tuple[FileFilter, ...], ) -> Optional[SaveDestination]: - del initial_directory, suggested_name, filters - return untyped_destination(self._take(DialogKind.SAVE, title)) + answer = self._take( + DialogKind.SAVE, + title=title, + initial_directory=initial_directory, + suggested_name=suggested_name, + filters=filters, + ) + return untyped_destination(answer) def select_directory( self, @@ -97,13 +114,22 @@ def select_directory( title: str, initial_directory: Optional[Path], ) -> Optional[Path]: - del initial_directory - return self._take(DialogKind.DIRECTORY, title) + return self._take( + DialogKind.DIRECTORY, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=(), + ) def _take( self, kind: DialogKind, + *, title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], ) -> Optional[Path]: with self._lock: queued = self._answers[kind] @@ -113,6 +139,9 @@ def _take( DialogRequest( kind=kind, title=title, + initial_directory=initial_directory, + suggested_name=suggested_name, + filters=filters, answered=answered, answer=answer, ) diff --git a/tests/suite/screens/boundaries/errors.py b/tests/suite/screens/boundaries/errors.py index 257f8e4ba..436d3cfcc 100644 --- a/tests/suite/screens/boundaries/errors.py +++ b/tests/suite/screens/boundaries/errors.py @@ -1,8 +1,9 @@ import inspect import logging import threading +from dataclasses import dataclass from types import CodeType -from typing import Final, List, Tuple +from typing import Final, List, Optional, Tuple from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_shared.application import SAMPLETONES_NAME @@ -10,6 +11,12 @@ EXIT_JOIN_CODE: Final[CodeType] = SingleThreadExecutor.join_all.__code__ +@dataclass +class _ErrorRecord: + message: str + claimed: bool + + class ErrorRecords(logging.Handler): """Every error the application logs, and every exception one of its threads lets escape. @@ -17,11 +24,14 @@ class ErrorRecords(logging.Handler): is why a scenario reads this record: a click that failed this way leaves the screen looking calm. The warning that background work outlived the exit's deadline counts as an error too, since the teardown destroys what that work still reaches. + + A scenario that provokes a failure claims the error it expects, and every error left unclaimed + is one the application reported on its own. """ def __init__(self) -> None: super().__init__(level=logging.WARNING) - self._messages: List[str] = [] + self._records: List[_ErrorRecord] = [] self._lock = threading.Lock() def install(self) -> None: @@ -29,9 +39,29 @@ def install(self) -> None: threading.excepthook = self._escaped @property - def messages(self) -> Tuple[str, ...]: + def count(self) -> int: + """How many errors have been recorded, claimed or not.""" + with self._lock: + return len(self._records) + + def unclaimed( + self, + start: int, + stop: Optional[int], + ) -> Tuple[str, ...]: + """The errors recorded from the ``start``-th up to the ``stop``-th, or on, that no scenario claimed.""" with self._lock: - return tuple(self._messages) + return tuple(record.message for record in self._records[start:stop] if not record.claimed) + + def claim(self, naming: str) -> bool: + """Takes the first unclaimed error whose message holds ``naming`` as provoked, and says whether one did.""" + with self._lock: + for record in self._records: + if not record.claimed and naming in record.message: + record.claimed = True + return True + + return False def emit(self, record: logging.LogRecord) -> None: if record.levelno < logging.ERROR and not _logged_by_the_exit_join(): @@ -45,7 +75,7 @@ def _escaped(self, arguments: threading.ExceptHookArgs) -> None: def _record(self, message: str) -> None: with self._lock: - self._messages.append(message) + self._records.append(_ErrorRecord(message=message, claimed=False)) def _logged_by_the_exit_join() -> bool: diff --git a/tests/suite/screens/checks.py b/tests/suite/screens/checks.py index 1b722c72c..a7f86987e 100644 --- a/tests/suite/screens/checks.py +++ b/tests/suite/screens/checks.py @@ -1,6 +1,6 @@ import threading from dataclasses import dataclass -from typing import List, Sequence, Tuple +from typing import List, Optional, Sequence, Tuple from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_WINDOW_MAIN from sampletones_application.utils.gui.modal_queue import ModalQueue, ModalQueueSnapshot @@ -33,9 +33,13 @@ def read_screen_state() -> ScreenState: ) -def quiet_findings(errors: ErrorRecords) -> List[str]: - """The errors the application logged or let escape a thread.""" - return [f"The application reported: {message}" for message in errors.messages] +def quiet_findings( + errors: ErrorRecords, + start: int, + stop: Optional[int], +) -> List[str]: + """The unprovoked errors the application logged or let escape a thread, from the ``start``-th to the ``stop``-th.""" + return [f"The application reported: {message}" for message in errors.unclaimed(start, stop)] def surviving_thread_findings(threads: Sequence[threading.Thread]) -> List[str]: diff --git a/tests/suite/screens/dearpygui/display.py b/tests/suite/screens/dearpygui/display.py index 9afa02f45..e20801e8a 100644 --- a/tests/suite/screens/dearpygui/display.py +++ b/tests/suite/screens/dearpygui/display.py @@ -15,6 +15,7 @@ class DisplayBackend(StrEnum): DisplayBackend.XVFB: "Xvfb", DisplayBackend.XEPHYR: "Xephyr", } +KEEP_STATE_BETWEEN_CLIENTS: Final[str] = "-noreset" class DisplayServerMissingError(RuntimeError): @@ -35,6 +36,11 @@ class VirtualDisplay: Xvfb draws into memory, which suits a run nobody watches. Xephyr draws into a window on the desktop, which lets a person follow a scenario as it plays. Either server takes every event a scenario sends, so the desktop around it stays untouched. + + The server goes on as it stands when its last client leaves. A server that resets then refuses + the connection an application opens a moment after another of its clients closed, and each + reset recompiles the keymap into output nobody reads, which stalls the server once that output + fills its pipe. """ def __init__( @@ -46,6 +52,7 @@ def __init__( self._server = Display( backend=backend.value, size=(size.width, size.height), + extra_args=[KEEP_STATE_BETWEEN_CLIENTS], manage_global_env=False, ) diff --git a/tests/suite/screens/dearpygui/geometry.py b/tests/suite/screens/dearpygui/geometry.py index 7907435fb..7fa7027f0 100644 --- a/tests/suite/screens/dearpygui/geometry.py +++ b/tests/suite/screens/dearpygui/geometry.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Sequence +from typing import Optional, Sequence @dataclass(frozen=True) @@ -43,6 +43,21 @@ def center(self) -> Point: y=round(self.y + self.height / 2), ) + def overlap(self, other: Rect) -> Optional[Rect]: + """The part of this box ``other`` covers too, or ``None`` where the two do not meet.""" + left = max(self.x, other.x) + top = max(self.y, other.y) + right = min(self.x + self.width, other.x + other.width) + bottom = min(self.y + self.height, other.y + other.height) + if right <= left or bottom <= top: + return None + + return Rect(x=left, y=top, width=right - left, height=bottom - top) + + def holds(self, point: Point) -> bool: + """Whether ``point`` lies within this box, edges included.""" + return self.x <= point.x <= self.x + self.width and self.y <= point.y <= self.y + self.height + def contains(self, other: Rect) -> bool: """Whether ``other`` lies within this box, edges included.""" return ( diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index f661a49d8..e17c620b1 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -6,7 +6,15 @@ from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge, RenderThreadStoppedError from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.items import Item, read_hovered, read_viewport +from tests.suite.screens.dearpygui.items import ( + Item, + enclosing_regions, + read_hovered, + read_item, + read_scroll, + read_viewport, + read_visible_box, +) from tests.suite.screens.dearpygui.keys import ( IMGUI_LEFT_CTRL, IMGUI_LEFT_SHIFT, @@ -26,6 +34,10 @@ DOUBLE_PRESS: Final[int] = 2 HOVER_ARRIVAL_FRAMES: Final[int] = 5 REACH_TIMEOUT_SECONDS: Final[float] = 10.0 +WHEEL_FRAMES: Final[int] = 1 +SCROLL_NOTCH_LIMIT: Final[int] = 400 +EDGE_INSET: Final[int] = 3 +DRAG_STEPS: Final[int] = 8 IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { MouseButton.LEFT: dpg.mvMouseButton_Left, MouseButton.RIGHT: dpg.mvMouseButton_Right, @@ -82,6 +94,61 @@ def hover(self, item: Item) -> None: self._bridge.frames(HOVER_FRAMES) self._confirm_hover(item) + def wheel( + self, + item: Item, + notches: int, + ) -> None: + """Rests the pointer over ``item`` and turns the wheel ``notches`` notches, down for a positive count.""" + self.hover(item) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def scroll_into_view(self, item: Item) -> None: + """Turns the wheel over the regions ``item`` scrolls in until the item stands whole in view. + + The nearest region that scrolls is moved first, at its right edge, and the regions around it + after. The wheel moves a region that takes it: the first notch shows how far one notch carries + the region, and each later turn takes as many notches as the distance left needs, the way a + person spins the wheel toward a row. A region the wheel leaves alone is moved by dragging its + scrollbar's grip to where the grip stands for the scroll the item needs. + + Raises: + UnreachableError: If a region stops moving, or the wheel runs out of turns, first. + """ + for region in self._bridge.ask(lambda: enclosing_regions(item)): + self._scroll_within(item, region) + + self._settle(SETTLE_FRAMES) + + def _scroll_within(self, item: Item, region: Item) -> None: + notch = 0.0 + for _ in range(SCROLL_NOTCH_LIMIT): + distance = self._bridge.ask(lambda: _distance_from_view(item, region)) + before = self._bridge.ask(lambda: read_scroll(region)) + if distance == 0 or before.maximum == 0: + return + + if before.by_wheel: + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 + self._turn_wheel(notches if distance > 0 else -notches) + else: + start = self._bridge.ask(lambda: _grip_point(region, before.position)) + end = self._bridge.ask(lambda: _grip_point(region, before.position + distance)) + self._drag_between(start, end) + notches = 1 + + self._settle(ONE_FRAME) + after = self._bridge.ask(lambda: read_scroll(region)) + if after == before: + raise UnreachableError(f"{item!r} stays out of view: the region {region!r} stopped at {before}") + + notch = abs(after.position - before.position) / notches + + raise UnreachableError(f"{item!r} stays out of view after {SCROLL_NOTCH_LIMIT} turns of the wheel") + def press_key( self, key: int, @@ -147,6 +214,32 @@ def _press( self._settle(SETTLE_FRAMES) + def _drag_between(self, start: Point, end: Point) -> None: + """Presses the left button at ``start``, carries it to ``end`` over a few frames, and lets go there.""" + self._device.move(start) + self._settle(HOVER_FRAMES) + self._device.button_down(MouseButton.LEFT) + self._settle(HOLD_FRAMES) + for step in range(1, DRAG_STEPS + 1): + self._device.move( + Point( + x=start.x + round((end.x - start.x) * step / DRAG_STEPS), + y=start.y + round((end.y - start.y) * step / DRAG_STEPS), + ) + ) + self._settle(ONE_FRAME) + + self._device.button_up(MouseButton.LEFT) + self._settle(RELEASE_FRAMES) + + def _turn_wheel(self, notches: int) -> None: + button = MouseButton.WHEEL_DOWN if notches > 0 else MouseButton.WHEEL_UP + for _ in range(abs(notches)): + self._device.button_down(button) + self._settle(WHEEL_FRAMES) + self._device.button_up(button) + self._settle(WHEEL_FRAMES) + def _tap(self, keysym: int) -> None: self._device.key_down(keysym) self._settle(HOLD_FRAMES) @@ -233,3 +326,61 @@ def read() -> Point: previous = point self._bridge.frames(ONE_FRAME) + + +def _distance_from_view(item: Item, region: Item) -> float: + """How far the region must scroll for ``item`` to stand whole in its view: down for a positive distance. + + Runs on the render thread. + """ + item_box = read_item(item).rect + region_box = read_item(region).rect + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + below = item_box.y + item_box.height - (region_box.y + region_box.height) + if below > 0: + return below + above = item_box.y - region_box.y + if above < 0: + return above + + return 0.0 + + +def _wheel_point(region: Item) -> Point: + """A pixel at the right edge of the part of ``region`` left in view, where its scrollbar stands. + + The middle of a region often lies over a smaller region it holds, which would take the wheel; + the edge belongs to the region itself. Runs on the render thread. + """ + visible = read_visible_box(region) + if visible is None: + raise UnreachableError(f"No part of the region {region!r} stands in view to turn the wheel over") + + viewport = read_viewport() + return Point( + x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, + y=round(viewport.y) + visible.center.y, + ) + + +def _grip_point(region: Item, scroll: float) -> Point: + """The pixel at the middle of ``region``'s scrollbar grip while the region stands scrolled to ``scroll``. + + The grip spans the share of the content the region shows, and travels the rest of the track as + the region scrolls from its top to its end. Runs on the render thread. + """ + visible = read_visible_box(region) + if visible is None: + raise UnreachableError(f"No part of the region {region!r} stands in view to drag its scrollbar") + + reading = read_scroll(region) + target = min(max(scroll, 0.0), reading.maximum) + grip = visible.height * visible.height / (visible.height + reading.maximum) + travel = visible.height - grip + viewport = read_viewport() + return Point( + x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, + y=round(viewport.y + visible.y + grip / 2 + travel * target / reading.maximum), + ) diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py index bb65a5e8d..a3d6d2e7e 100644 --- a/tests/suite/screens/dearpygui/items.py +++ b/tests/suite/screens/dearpygui/items.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Final, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Final, List, Optional, Tuple, Union import dearpygui.dearpygui as dpg @@ -8,8 +8,11 @@ Item = Union[int, str] WINDOW_TYPE: Final[str] = "mvAppItemType::mvWindowAppItem" +CHILD_WINDOW_TYPE: Final[str] = "mvAppItemType::mvChildWindow" +TREE_NODE_TYPE: Final[str] = "mvAppItemType::mvTreeNode" MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" +TEXT_TYPE: Final[str] = "mvAppItemType::mvText" TAG_SEPARATOR: Final[str] = "." @@ -22,7 +25,7 @@ class ItemReading: exists: Whether the item is in the context at all. shown: Whether the item and every container around it are shown. visible: Whether the item was drawn inside its clip region in the last frame. - enabled: Whether the item answers a press. + enabled: Whether the item and every container around it answer a press. rect: The item's box on the screen, where its kind reports one. """ @@ -34,6 +37,21 @@ class ItemReading: rect: Optional[Rect] +@dataclass(frozen=True) +class ScrollReading: + """How far a region that scrolls stands from its top, how far it can go, in pixels, and what moves it. + + Attributes: + position: How far the region is scrolled down. + maximum: How far the region can scroll down. + by_wheel: Whether the wheel scrolls the region, or its scrollbar alone does. + """ + + position: float + maximum: float + by_wheel: bool + + @dataclass(frozen=True) class WindowReading: """One top-level window as the last frame drew it. @@ -72,7 +90,7 @@ def read_item(item: Item) -> ItemReading: exists=True, shown=_shown_with_ancestors(item), visible=bool(state.get("visible", True)), - enabled=bool(configuration.get("enabled", True)), + enabled=bool(configuration.get("enabled", True)) and _enabled_ancestors(item), rect=_rect(item, state), ) @@ -98,6 +116,70 @@ def read_windows() -> Tuple[WindowReading, ...]: return tuple(windows) +def read_scroll(region: Item) -> ScrollReading: + """How far ``region`` is scrolled down. Runs on the render thread.""" + return ScrollReading( + position=dpg.get_y_scroll(region), + maximum=dpg.get_y_scroll_max(region), + by_wheel=not dpg.get_item_configuration(region).get("no_scroll_with_mouse", False), + ) + + +def enclosing_regions(item: Item) -> Tuple[Item, ...]: + """The regions around ``item`` that clip what they hold, the nearest first. Runs on the render thread.""" + regions: List[Item] = [] + parent = dpg.get_item_parent(item) + while parent is not None: + if dpg.get_item_info(parent)["type"] == CHILD_WINDOW_TYPE: + regions.append(parent) + + parent = dpg.get_item_parent(parent) + + return tuple(regions) + + +def read_visible_box(item: Item) -> Optional[Rect]: + """The part of ``item``'s box the regions around it and the viewport leave in view. Runs on the render thread.""" + box = read_item(item).rect + for region in enclosing_regions(item): + region_box = read_item(region).rect + if box is None or region_box is None: + return None + + box = box.overlap(region_box) + + return box.overlap(read_client_area()) if box is not None else None + + +def read_table(table: Item) -> Tuple[Tuple[Tuple[Item, ...], ...], ...]: + """The items inside each cell of each row of ``table``, row by row. Runs on the render thread. + + A table keeps its columns in one slot of children and its rows in another, and each row holds + one cell per column, whatever item the cell carries. + """ + rows = dpg.get_item_info(table)["children"][1] + return tuple( + tuple(tuple(dpg.get_item_info(cell)["children"][1]) for cell in dpg.get_item_info(row)["children"][1]) + for row in rows + ) + + +def find_item( + container: Item, + matches: Callable[[Item], bool], +) -> Optional[Item]: + """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. Runs on the render thread.""" + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if matches(item): + return item + + pending[:0] = [child for children in dpg.get_item_info(item)["children"].values() for child in children] + + return None + + def read_hovered(item: Item) -> Optional[bool]: """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. @@ -111,6 +193,15 @@ def read_hovered(item: Item) -> Optional[bool]: return bool(state["hovered"]) +def read_theme(item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, if one is. Runs on the render thread.""" + theme = dpg.get_item_info(item)["theme"] + if theme is None: + return None + + return str(dpg.get_item_alias(theme)) + + def read_label(item: Item) -> str: """The label ``item`` carries. Runs on the render thread.""" return str(dpg.get_item_label(item) or "") @@ -121,6 +212,36 @@ def read_value(item: Item) -> Any: return dpg.get_value(item) +def read_shown_texts(container: Item) -> Tuple[str, ...]: + """The words of every shown text item under ``container``, in the order they are drawn. Runs on the render thread.""" + return _texts(container, shown_only=True) + + +def read_texts(container: Item) -> Tuple[str, ...]: + """The words of every text item under ``container``, shown or waiting to be, such as a tooltip's. + + Runs on the render thread. + """ + return _texts(container, shown_only=False) + + +def _texts(container: Item, *, shown_only: bool) -> Tuple[str, ...]: + texts: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if shown_only and not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == TEXT_TYPE: + texts.append(str(dpg.get_value(item))) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(texts) + + def find_labelled( container: Item, label: str, @@ -163,6 +284,11 @@ def read_viewport() -> Rect: ) +def read_viewport_title() -> str: + """The title the viewport's window carries in its title bar. Runs on the render thread.""" + return str(dpg.get_viewport_title()) + + def read_client_area() -> Rect: """The viewport's client area in the coordinates its items report. Runs on the render thread.""" return Rect( @@ -189,16 +315,60 @@ def _shown_with_ancestors(tag: Item) -> bool: return True +def _enabled_ancestors(tag: Item) -> bool: + parent = dpg.get_item_parent(tag) + while parent is not None: + if not dpg.get_item_configuration(parent).get("enabled", True): + return False + + parent = dpg.get_item_parent(parent) + + return True + + def _rect(item: Item, state: Dict[str, Any]) -> Optional[Rect]: if "rect_min" in state: return Rect.of(state["rect_min"], state["rect_size"]) - if dpg.get_item_info(item)["type"] == WINDOW_TYPE: + item_type = dpg.get_item_info(item)["type"] + if item_type == WINDOW_TYPE: return _window_rect(item) + if item_type == CHILD_WINDOW_TYPE: + return Rect.of(_child_window_corner(item), state["rect_size"]) return None +def _child_window_corner(child: Item) -> Tuple[float, float]: + """Where a child window stands on the screen. + + DearPyGui reports a child window's position in the content of the window holding it, which + scrolls, so the corner is that window's own corner moved by the position and back by the scroll. + """ + container = _containing_window(child) + if dpg.get_item_info(container)["type"] == WINDOW_TYPE: + corner = dpg.get_item_pos(container) + else: + corner = _child_window_corner(container) + + position = dpg.get_item_state(child)["pos"] + return ( + corner[0] + position[0] - dpg.get_x_scroll(container), + corner[1] + position[1] - dpg.get_y_scroll(container), + ) + + +def _containing_window(item: Item) -> Item: + parent: Optional[Item] = dpg.get_item_parent(item) + while parent is not None and dpg.get_item_info(parent)["type"] not in (WINDOW_TYPE, CHILD_WINDOW_TYPE): + parent = dpg.get_item_parent(parent) + + if parent is None: + raise LookupError(f"{item!r} stands in no window") + + return parent + + def _window_rect(window: Item) -> Rect: return Rect.of( dpg.get_item_pos(window), diff --git a/tests/suite/screens/dearpygui/reach.py b/tests/suite/screens/dearpygui/reach.py index 07a7c8969..7843bf8d7 100644 --- a/tests/suite/screens/dearpygui/reach.py +++ b/tests/suite/screens/dearpygui/reach.py @@ -3,7 +3,14 @@ import dearpygui.dearpygui as dpg from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import Item, ItemReading, read_client_area, read_item, read_windows +from tests.suite.screens.dearpygui.items import ( + Item, + ItemReading, + read_client_area, + read_item, + read_visible_box, + read_windows, +) class UnreachableError(AssertionError): @@ -14,8 +21,8 @@ def reachable(item: Item) -> Rect: """The box of ``item``, once it stands where a user could press it. Runs on the render thread. A control is in reach when it exists, it and every container around it are shown, it was drawn - in the last frame, it answers a press, its box lies inside the viewport, and no modal window of - another tree holds the screen. + in the last frame, it answers a press, its box lies inside the viewport, its middle lies in the + part the regions around it leave in view, and no modal window of another tree holds the screen. Raises: UnreachableError: Naming the first condition the control misses. @@ -43,6 +50,10 @@ def _refusal(reading: ItemReading) -> Optional[str]: if not read_client_area().contains(reading.rect): return f"its box {reading.rect} reaches outside the viewport" + visible = read_visible_box(reading.item) + if visible is None or not visible.holds(reading.rect.center): + return "its middle lies outside the part the regions around it leave in view" + covering = covering_modal(reading.item) if covering is not None: return f"the modal window '{covering}' holds the screen" diff --git a/tests/suite/screens/dearpygui/semantic.py b/tests/suite/screens/dearpygui/semantic.py index 0377e9d7d..3606f3c72 100644 --- a/tests/suite/screens/dearpygui/semantic.py +++ b/tests/suite/screens/dearpygui/semantic.py @@ -37,6 +37,32 @@ def invoke( run(callback, *_declared(callback, arguments)) +def choose( + item: Item, + value: Any, + run: CallbackRunner, +) -> None: + """Sets ``item`` to ``value`` and runs its callback as a pick of that option does. Runs on the render thread. + + This is the gesture for a choice DearPyGui draws as one item, such as a radio button's options or a + combo's entries. The choice must stand in reach the way :func:`invoke` asks. + + Raises: + UnreachableError: Naming what keeps a user from the choice. + """ + refusal = _refusal(read_item(item)) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + callback = dpg.get_item_callback(item) + if callback is None: + raise UnreachableError(f"{item!r} answers no choice: it carries no callback") + + dpg.set_value(item, value) + arguments = (item, value, dpg.get_item_user_data(item)) + run(callback, *_declared(callback, arguments)) + + def _refusal(reading: ItemReading) -> Optional[str]: if not reading.exists: return "it does not exist" diff --git a/tests/suite/screens/dearpygui/xtest.py b/tests/suite/screens/dearpygui/xtest.py index a06a9bdb7..f32ac4919 100644 --- a/tests/suite/screens/dearpygui/xtest.py +++ b/tests/suite/screens/dearpygui/xtest.py @@ -14,6 +14,8 @@ class MouseButton(IntEnum): LEFT = 1 MIDDLE = 2 RIGHT = 3 + WHEEL_UP = 4 + WHEEL_DOWN = 5 class UntypableCharacterError(LookupError): diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index 7a6047863..e426604ad 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -219,6 +219,7 @@ def screen( language=screen_application.language, shortcuts=screen_application.shortcuts, dialogs=screen_boundaries.dialogs, + errors=screen_boundaries.errors, artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), ) window_manager.close() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 24e457247..8c318d360 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -1,20 +1,35 @@ from pathlib import Path -from typing import Callable, Final, Optional, TypeVar +from typing import Callable, Final, Optional, Tuple, TypeVar, Union +from sampletones_application.categories.key.text import TextKeyTuple from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.general import ( + TAG_GLOBAL_DIALOG_ERROR, + TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, + TAG_GLOBAL_WINDOW_MAIN, +) +from sampletones_application.tags.main import TAG_MAIN_EXPLORER_TREE from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from tests.suite.scenario import BaseTestScenario, ScenarioStep -from tests.suite.screens.boundaries.dialogs import DialogKind, ScriptedFileDialogs -from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.dearpygui.bridge import Bridge, ExpectationError from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import Item, WindowReading, read_viewport_title, read_windows from tests.suite.screens.dearpygui.screenshot import capture from tests.suite.screens.dearpygui.windows import WindowManager from tests.suite.screens.keyboard import press_combination, primary_combination from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.display_settings import DisplaySettings +from tests.suite.screens.views.instructions import Instructions +from tests.suite.screens.views.main import Main from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.notices import Notice from tests.suite.screens.views.project import Project +from tests.suite.screens.views.reconstructions import Reconstructions +from tests.suite.screens.views.sequencer import Sequencer from tests.suite.screens.views.tabs import Tabs ReadingT = TypeVar("ReadingT") @@ -43,6 +58,7 @@ def __init__( language: LanguageManager, shortcuts: ShortcutManager, dialogs: ScriptedFileDialogs, + errors: ErrorRecords, artifacts: Path, ) -> None: self.bridge = bridge @@ -51,11 +67,20 @@ def __init__( self._render_thread = render_thread self._shortcuts = shortcuts self._dialogs = dialogs + self._errors = errors self._artifacts = artifacts + self._language = language self.tabs = Tabs(bridge, hand) self.menu = MenuBar(bridge, language) self.display_settings = DisplaySettings(bridge, hand, self.menu) self.project = Project(bridge, hand, self.menu) + self.main = Main(bridge, hand, language) + self.explorer = FileTree(bridge, hand, TAG_MAIN_EXPLORER_TREE) + self.reconstructions = Reconstructions(bridge, hand, self.menu) + self.sequencer = Sequencer(bridge, self.menu) + self.instructions = Instructions(bridge, hand) + self.error_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_ERROR) + self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) def expect( self, @@ -72,6 +97,27 @@ def expect( timeout=EXPECT_TIMEOUT_SECONDS, ) + def words(self, key: Union[str, TextKeyTuple]) -> str: + """What the language file says under ``key``, which is what the application shows the user.""" + return self._language[key] + + def expect_item( + self, + reading: Callable[[], Optional[Item]], + *, + description: str, + ) -> Item: + """Takes ``reading`` once a frame until it finds an item, and returns the item. + + Raises: + ExpectationError: If no item is found within the expectation's time. + """ + found = self.expect(reading, lambda value: value is not None, description=description) + if found is None: + raise ExpectationError(f"Expected {description}, and found nothing") + + return found + def frames(self, count: int) -> None: """Lets ``count`` frames pass, for a scenario whose promise is itself a number of frames.""" self.bridge.frames(count) @@ -80,6 +126,10 @@ def capture(self, name: str) -> Path: """Keeps a picture of the next frame beside the scenario's other files, as evidence of a look.""" return capture(self.bridge, (self._artifacts / name).with_suffix(SCREENSHOT_SUFFIX)) + def title(self) -> str: + """The title on the window's title bar, which names the documents open.""" + return self.bridge.ask(read_viewport_title) + def is_running(self) -> bool: """Whether the application still draws its window.""" return self._render_thread.is_running() @@ -92,6 +142,18 @@ def wait_for_exit(self) -> bool: """Waits for the application to stop, and says whether it did within the expectation's time.""" return self._render_thread.wait_stopped(EXPECT_TIMEOUT_SECONDS) + def dialog_requests(self) -> Tuple[DialogRequest, ...]: + """Every file dialog the application opened so far, in order.""" + return self._dialogs.requests + + def shown_windows(self) -> Tuple[WindowReading, ...]: + """Every window standing on the screen besides the application's main one.""" + return tuple( + window + for window in self.bridge.ask(read_windows) + if window.shown and window.alias != TAG_GLOBAL_WINDOW_MAIN + ) + def answer_next_dialog( self, kind: DialogKind, @@ -100,6 +162,18 @@ def answer_next_dialog( """Answers the next file dialog of ``kind`` with ``path``, or dismisses it for ``None``.""" self._dialogs.answer(kind, path) + def claim_error(self, naming: str) -> None: + """Waits for the error a scenario provoked on purpose, one whose message holds ``naming``. + + A claimed error is the application reporting a failure as it should, so the after-checks leave + it out. An error nobody claims still fails the scenario. + """ + self.expect( + lambda: self._errors.claim(naming), + bool, + description=f"an error naming '{naming}'", + ) + def press_shortcut(self, shortcut_id: ShortcutId) -> None: """Presses the keys the scheme in place gives ``shortcut_id`` on the real keyboard.""" press_combination(self.hand, primary_combination(self._shortcuts.shortcut(shortcut_id))) diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py new file mode 100644 index 000000000..9c5d24c05 --- /dev/null +++ b/tests/suite/screens/seeds.py @@ -0,0 +1,100 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Final + +import numpy as np +import soundfile + +from sampletones_core.compatibility.kind import ObjectKind +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document + +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" +TRUNCATED_FRACTION: Final[int] = 2 +RECORDING_SAMPLE_RATE: Final[int] = 44100 +RECORDING_AMPLITUDE: Final[float] = 0.5 + + +class Damage(StrEnum): + """What went wrong with a stored document before the application met it.""" + + OLDER_VERSION = "older version" + FUTURE_VERSION = "future version" + TRUNCATED = "truncated" + FOREIGN_BYTES = "foreign bytes" + + +@dataclass(frozen=True) +class CopiedFile: + """A file laid in the home as it stands elsewhere.""" + + source: Path + destination: Path + + def write(self) -> None: + self.destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.source, self.destination) + + +@dataclass(frozen=True) +class WrittenBytes: + """A file in the home holding exactly ``content``.""" + + destination: Path + content: bytes + + def write(self) -> None: + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(self.content) + + +@dataclass(frozen=True) +class Recording: + """A recording in the home: a sine tone of ``frequency`` hertz lasting ``seconds``.""" + + destination: Path + seconds: float + frequency: float + + def write(self) -> None: + self.destination.parent.mkdir(parents=True, exist_ok=True) + times = np.arange(round(self.seconds * RECORDING_SAMPLE_RATE)) / RECORDING_SAMPLE_RATE + tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * self.frequency * times) + soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE) + + +def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: + """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at ``destination``.""" + return CopiedFile( + source=archived(kind, ARCHIVED_VERSIONS[kind]), + destination=destination, + ) + + +def damaged_document( + kind: ObjectKind, + damage: Damage, + *, + destination: Path, + older_version: str, + future_version: str, +) -> WrittenBytes: + """The archived document of ``kind`` with ``damage`` done to it, laid at ``destination``. + + A document restated at ``older_version`` or ``future_version`` is whole apart from its version, so + a refusal of it is a refusal of that version alone. + """ + source = archived(kind, ARCHIVED_VERSIONS[kind]) + match damage: + case Damage.OLDER_VERSION: + content = restated_document(source, kind, older_version) + case Damage.FUTURE_VERSION: + content = restated_document(source, kind, future_version) + case Damage.TRUNCATED: + whole = source.read_bytes() + content = whole[: len(whole) // TRUNCATED_FRACTION] + case Damage.FOREIGN_BYTES: + content = FOREIGN_BYTES + + return WrittenBytes(destination=destination, content=content) diff --git a/tests/suite/screens/views/browsers.py b/tests/suite/screens/views/browsers.py new file mode 100644 index 000000000..e62d3b82f --- /dev/null +++ b/tests/suite/screens/views/browsers.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import Callable, List, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_core.structures.tree.node import FileSystemNode, LibraryNode, TreeNode +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import TREE_NODE_TYPE, Item, find_item, read_label + +NodeTest = Callable[[TreeNode], bool] + + +class FileTree: + """A tree of rows on one of the tabs, each row standing for the node of the tree it was drawn from. + + A row carries its node, so the view finds a row by the file or the library the node names, + and a heading by the words it shows. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tree: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tree = tree + + def file_row(self, path: Path) -> Optional[Item]: + """The first row naming the file or folder at ``path``, if one is drawn.""" + return self._row(lambda node: isinstance(node, FileSystemNode) and node.filepath == path) + + def file_rows(self, path: Path) -> List[Item]: + """Every row naming the file at ``path``, which a browser listing it under several headings draws more than once.""" + + def rows() -> List[Item]: + found: List[Item] = [] + find_item(self._tree, lambda item: _collect(item, path, found)) + return found + + return self._bridge.ask(rows) + + def library_row(self, matches: Callable[[LibraryNode], bool]) -> Optional[Item]: + """The first row standing for a library ``matches`` accepts, if one is drawn.""" + return self._row(lambda node: isinstance(node, LibraryNode) and matches(node)) + + def heading(self, label: str) -> Optional[Item]: + """The first row whose words read ``label``, such as a heading the tree groups its files under.""" + return self._bridge.ask( + lambda: find_item( + self._tree, + lambda item: _is_tree_row(item) and read_label(item).strip() == label.strip(), + ) + ) + + def label(self, row: Item) -> str: + return self._bridge.ask(lambda: read_label(row)) + + def is_open(self, row: Item) -> bool: + """Whether ``row`` stands open onto the rows under it.""" + return bool(self._bridge.ask(lambda: dpg.get_value(row))) + + def open_by_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it, which opens or closes a row of a tree opened by a click.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def double_click(self, row: Item) -> None: + """Brings ``row`` into view and double-clicks it.""" + self._hand.scroll_into_view(row) + self._hand.double_click(row) + + def _row(self, matches: NodeTest) -> Optional[Item]: + return self._bridge.ask( + lambda: find_item( + self._tree, + lambda item: _is_tree_row(item) and _node_matches(item, matches), + ) + ) + + +def _is_tree_row(item: Item) -> bool: + return str(dpg.get_item_info(item)["type"]) == TREE_NODE_TYPE + + +def _node_matches(item: Item, matches: NodeTest) -> bool: + user_data = dpg.get_item_user_data(item) + if not isinstance(user_data, tuple) or not user_data: + return False + + node = user_data[0] + return isinstance(node, TreeNode) and bool(matches(node)) + + +def _collect(item: Item, path: Path, found: List[Item]) -> bool: + if _is_tree_row(item) and _node_matches( + item, lambda node: isinstance(node, FileSystemNode) and node.filepath == path + ): + found.append(item) + + return False diff --git a/tests/suite/screens/views/instructions.py b/tests/suite/screens/views/instructions.py new file mode 100644 index 000000000..dd8439f26 --- /dev/null +++ b/tests/suite/screens/views/instructions.py @@ -0,0 +1,44 @@ +from typing import Optional + +from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS, + TAG_INSTRUCTIONS_LIBRARY_TREE, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import Item, read_value +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.prompts import Prompt + + +class Library: + """The Instructions tab's library card: the libraries its folder holds, and the line saying where they stand.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self.tree = FileTree(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_TREE) + self.rebuild_prompt = Prompt(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION) + + def row(self, filename: str) -> Optional[Item]: + """The row standing for the library stored under ``filename``, if one is drawn.""" + return self.tree.library_row(lambda node: node.library_key.filename == filename) + + def status(self) -> str: + """What the line under the tree says about the library in hand.""" + return str(self._bridge.ask(lambda: read_value(TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS))) + + +class Instructions: + """The Instructions tab, as far as scenarios read it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self.library = Library(bridge, hand) diff --git a/tests/suite/screens/views/main.py b/tests/suite/screens/views/main.py new file mode 100644 index 000000000..2afc7f9e0 --- /dev/null +++ b/tests/suite/screens/views/main.py @@ -0,0 +1,121 @@ +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_COLLAPSE_BODY, + SUF_COLLAPSE_STRIP, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_STEP_BUTTON_LIT, +) +from sampletones_application.tags.main import ( + PRE_MAIN_SOURCE_STEP, + TAG_MAIN_ADVANCED_BUTTON_SELECT_LIBRARY_DIRECTORY, + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PATH_LIBRARY_DIRECTORY_DISPLAY, + TAG_MAIN_CONVERTER_RADIO_MODE, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.main.source import CHANNEL_CAP_STEPS +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_texts, read_theme, read_value +from tests.suite.screens.dearpygui.semantic import choose + +OUTPUT_LABEL_KEYS = { + OutputKind.PER_RECORDING: "main.converter.label.mode_each", + OutputKind.MIXED: "main.converter.label.mode_mixed", +} + + +class Card: + """A card of the interface that folds into its header bar at a click on the bar.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tag = tag + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(self.tag)).shown + + def is_collapsed(self) -> bool: + """Whether the card stands folded into its bar, its body put away.""" + return not self._bridge.ask(lambda: read_item(compose_tag(self.tag, SUF_COLLAPSE_BODY))).shown + + def toggle(self) -> None: + """Clicks the card's header bar, which folds an open card and opens a folded one.""" + strip = compose_tag(self.tag, SUF_COLLAPSE_STRIP) + self._hand.scroll_into_view(strip) + self._hand.click(strip) + + +class Main: + """The Main tab: its cards, the converter's settings and the folders the advanced settings name.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + language: LanguageManager, + ) -> None: + self._bridge = bridge + self._hand = hand + self._language = language + self.advanced = Card(bridge, hand, TAG_MAIN_ADVANCED_PANEL) + + def card(self, tag: str) -> Card: + return Card(self._bridge, self._hand, tag) + + def output(self) -> OutputKind: + """What the converter's output switch stands at.""" + label = self._bridge.ask(lambda: read_value(TAG_MAIN_CONVERTER_RADIO_MODE)) + for output, key in OUTPUT_LABEL_KEYS.items(): + if self._language[key] == label: + return output + + raise LookupError(f"The output switch reads '{label}', which names no output") + + def choose_output(self, output: OutputKind) -> None: + """Picks ``output`` on the converter's output switch.""" + label = self._language[OUTPUT_LABEL_KEYS[output]] + self._bridge.ask(lambda: choose(TAG_MAIN_CONVERTER_RADIO_MODE, label, CallbackQueue.run)) + + def choose_channels_at_once(self, count: int) -> None: + """Clicks the step naming ``count`` channels at once.""" + self._hand.scroll_into_view(_step_tag(count)) + self._hand.click(_step_tag(count)) + + def lit_channels_at_once(self) -> Optional[int]: + """The step of Channels at once that stands lit, if one does.""" + + def read() -> Optional[int]: + for step in CHANNEL_CAP_STEPS: + if read_theme(_step_tag(step)) == TAG_GLOBAL_THEME_STEP_BUTTON_LIT: + return step + + return None + + return self._bridge.ask(read) + + def choose_library_directory(self) -> None: + """Clicks the button that asks for the library folder.""" + button = compose_tag(TAG_MAIN_ADVANCED_BUTTON_SELECT_LIBRARY_DIRECTORY, SUF_BUTTON) + self._hand.scroll_into_view(button) + self._hand.click(button) + + def library_directory(self) -> str: + """The whole path of the library folder, as the hover over its shortened path shows it.""" + tooltip = compose_tag(TAG_MAIN_ADVANCED_PATH_LIBRARY_DIRECTORY_DISPLAY, SUF_TOOLTIP) + return " ".join(self._bridge.ask(lambda: read_texts(tooltip))) + + +def _step_tag(step: int) -> str: + return compose_tag(PRE_MAIN_SOURCE_STEP, str(step), SUF_BUTTON) diff --git a/tests/suite/screens/views/notices.py b/tests/suite/screens/views/notices.py new file mode 100644 index 000000000..a323a1b5f --- /dev/null +++ b/tests/suite/screens/views/notices.py @@ -0,0 +1,43 @@ +from typing import Tuple + +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_shown_texts +from tests.suite.screens.views.prompts import MissingPromptError, Prompt + + +class Notice: + """A dialog telling the reader something, such as a failure, which the reader acknowledges with OK. + + Each kind of notice composes its windows under a tag of its own, which is the one the view is + given. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self.prompt = Prompt(bridge, hand, tag) + + def is_shown(self) -> bool: + return self.prompt.is_shown() + + def words(self) -> str: + """Everything the notice says, its lines joined by spaces. + + Raises: + MissingPromptError: If no notice of this kind stands on the screen. + """ + window = self.prompt.window() + if window is None: + raise MissingPromptError("No notice of this kind stands on the screen") + + texts: Tuple[str, ...] = self._bridge.ask(lambda: read_shown_texts(window.alias)) + return " ".join(texts) + + def dismiss(self) -> None: + """Presses OK.""" + self.prompt.confirm() diff --git a/tests/suite/screens/views/project.py b/tests/suite/screens/views/project.py index 9f840c566..5cfdb3342 100644 --- a/tests/suite/screens/views/project.py +++ b/tests/suite/screens/views/project.py @@ -2,6 +2,7 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, + TAG_GLOBAL_DIALOG_PROJECT_OPEN, TAG_GLOBAL_DIALOG_PROJECT_SAVED, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, ) @@ -61,6 +62,11 @@ def __init__( self.properties = ProjectProperties(bridge, hand, menu) self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED) self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_SAVED) + self.replace_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_OPEN) + + def open(self) -> None: + """Chooses File โ–ธ Open project, which asks for a file.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT) def create(self) -> None: """Starts a new project from File โ–ธ New project.""" diff --git a/tests/suite/screens/views/reconstructions.py b/tests/suite/screens/views/reconstructions.py new file mode 100644 index 000000000..13cb232e7 --- /dev/null +++ b/tests/suite/screens/views/reconstructions.py @@ -0,0 +1,45 @@ +from pathlib import Path + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_TOOLTIP +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_BROWSER_TREE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_texts +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.menus import MenuBar + +OPEN_FILE_TOOLTIP = compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, SUF_TOOLTIP) + + +class Reconstructions: + """The Reconstructions tab: the browser of saved reconstructions, and the one open beside it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + self.browser = FileTree(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_TREE) + + def open_from_menu(self) -> None: + """Chooses Reconstruction โ–ธ Open, which asks for a file.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_OPEN) + + def open_file(self) -> str: + """The whole path of the open reconstruction's file, as the hover over its shortened path shows it. + + Nothing open, or a reconstruction holding no file, reads as the status the line shows instead. + """ + return " ".join(self._bridge.ask(lambda: read_texts(OPEN_FILE_TOOLTIP))) + + def shows_open(self, path: Path) -> bool: + """Whether the reconstruction open is the one stored at ``path``.""" + return self.open_file() == str(path.absolute()) diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py new file mode 100644 index 000000000..4d225fa40 --- /dev/null +++ b/tests/suite/screens/views/sequencer.py @@ -0,0 +1,66 @@ +from typing import Final, List + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.items import read_item, read_label, read_table +from tests.suite.screens.views.menus import MenuBar + +NAME_COLUMN: Final[int] = 2 +CELL_CONTENT: Final[int] = 0 + + +class Voices: + """The Voices card of the Sequencer: one row per voice of the project, its number and its name.""" + + def __init__(self, bridge: Bridge) -> None: + self._bridge = bridge + + def names(self) -> List[str]: + """The names the rows show, top to bottom.""" + + def read() -> List[str]: + return [read_label(row[NAME_COLUMN][CELL_CONTENT]) for row in read_table(TAG_SEQUENCER_VOICES_TABLE)] + + return self._bridge.ask(read) + + +class Playback: + """Song playback as the Playback menu offers it: Play, which reads Pause while a song plays, and Stop.""" + + def __init__( + self, + bridge: Bridge, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + + def play(self) -> None: + """Chooses Playback โ–ธ Play.""" + self._menu.choose(MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY) + + def stop(self) -> None: + """Chooses Playback โ–ธ Stop.""" + self._menu.choose(MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_STOP) + + def play_entry(self) -> str: + """What the first entry of the Playback menu reads: Play while stopped, Pause while a song plays.""" + return self._bridge.ask(lambda: read_label(TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) + + def can_stop(self) -> bool: + """Whether Playback โ–ธ Stop answers, which it does while something plays.""" + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP)).enabled + + +class Sequencer: + """The Sequencer tab, as far as scenarios read it.""" + + def __init__( + self, + bridge: Bridge, + menu: MenuBar, + ) -> None: + self.voices = Voices(bridge) + self.playback = Playback(bridge, menu) diff --git a/tests/suite/screens/written.py b/tests/suite/screens/written.py new file mode 100644 index 000000000..36a8fb89f --- /dev/null +++ b/tests/suite/screens/written.py @@ -0,0 +1,23 @@ +from sampletones_application.config.managers.application import ApplicationConfigManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.state import ApplicationStateManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH + + +def written_state() -> ApplicationState: + """The session the application left in its home, read by the manager that restores it at the next start.""" + return ApplicationStateManager(UserProfile.user().state).state + + +def written_application_config() -> ApplicationConfig: + """The application settings left in the home, read by the manager the next start reads them with.""" + return ApplicationConfigManager(UserProfile.user().config).config + + +def written_config() -> Config: + """The reconstruction settings left in the documents folder, read by the manager the next start reads them with.""" + return ConfigManager(CONFIG_PATH).config From 3fb7ab03cd8dc13f6c503815cfe53fd7eaeda940 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 13:27:43 +0200 Subject: [PATCH 08/19] Proved: the Main tab's gathering, list, row settings, mix, cards, runs and library on a live screen --- docs/development/application/screens.md | 12 + docs/development/bugs-and-todos.md | 17 + tests/screens/conftest.py | 6 + tests/screens/main/__init__.py | 0 tests/screens/main/test_cards.py | 163 +++++++ tests/screens/main/test_gathering.py | 334 +++++++++++++++ tests/screens/main/test_library.py | 314 ++++++++++++++ tests/screens/main/test_list.py | 466 +++++++++++++++++++++ tests/screens/main/test_mix.py | 202 +++++++++ tests/screens/main/test_row_settings.py | 419 ++++++++++++++++++ tests/screens/main/test_run.py | 423 +++++++++++++++++++ tests/screens/main/test_scan.py | 186 ++++++++ tests/suite/library.py | 41 +- tests/suite/screens/application.py | 5 +- tests/suite/screens/dearpygui/hand.py | 177 ++++++-- tests/suite/screens/dearpygui/items.py | 73 +++- tests/suite/screens/dearpygui/recording.py | 63 +++ tests/suite/screens/holds.py | 162 +++++++ tests/suite/screens/plugin.py | 39 ++ tests/suite/screens/screen.py | 42 +- tests/suite/screens/seeds.py | 29 +- tests/suite/screens/steps/main.py | 37 ++ tests/suite/screens/views/browsers.py | 16 + tests/suite/screens/views/context_menu.py | 69 +++ tests/suite/screens/views/converter.py | 264 ++++++++++++ tests/suite/screens/views/instructions.py | 26 +- tests/suite/screens/views/main.py | 4 + tests/suite/screens/views/source.py | 40 ++ tests/suite/screens/world.py | 19 + 29 files changed, 3607 insertions(+), 41 deletions(-) create mode 100644 tests/screens/main/__init__.py create mode 100644 tests/screens/main/test_cards.py create mode 100644 tests/screens/main/test_gathering.py create mode 100644 tests/screens/main/test_library.py create mode 100644 tests/screens/main/test_list.py create mode 100644 tests/screens/main/test_mix.py create mode 100644 tests/screens/main/test_row_settings.py create mode 100644 tests/screens/main/test_run.py create mode 100644 tests/screens/main/test_scan.py create mode 100644 tests/suite/screens/dearpygui/recording.py create mode 100644 tests/suite/screens/holds.py create mode 100644 tests/suite/screens/steps/main.py create mode 100644 tests/suite/screens/views/context_menu.py create mode 100644 tests/suite/screens/views/converter.py create mode 100644 tests/suite/screens/views/source.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index c21f96629..d44fe9eee 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -149,6 +149,17 @@ and a scenario reads as the gestures a user makes. - **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request a title bar's close button sends. +## Holding work in flight + +A gesture made while work runs is only proven if the work is provably still running when the gesture +lands. A hold keeps it there: a stand-in decides **when** the work lands and never **what** it computes. +A held conversion walks the stages a real one reports and stops halfway through matching, and a held +folder scan meets the folder's entries and then one entry after another that is no recording. Each goes +on reporting while it is held, so Stop and Cancel are heard where the real work hears them. A hold lasts +until the scenario releases it, and every hold is released before the scenario leaves, because leaving +waits for the work in flight. A scenario asks for a hold through its fixture, `conversion_hold` or +`scan_hold`. + ## What survives a restart What a restart keeps is proven in two halves, each a scenario on a fresh home. In the first, the application @@ -207,6 +218,7 @@ picture as evidence of a look, for a pull request rather than an assertion. | The display a worker draws on | `tests/suite/screens/dearpygui/display.py` | | The application under test and how it ends | `tests/suite/screens/application.py` | | The stand-ins at the boundaries | `tests/suite/screens/boundaries/` | +| Work held in flight | `tests/suite/screens/holds.py` | | The after-checks | `tests/suite/screens/checks.py` | | What a scenario holds and reads | `tests/suite/screens/screen.py`, `views/`, `steps/` | | What a scenario's home holds | `tests/suite/screens/world.py` | diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8b9ad506d..d3e53432e 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -165,3 +165,20 @@ currently out of line. An entry leaves when the code meets the contract again. reproduces it as a known failure. * Leaving keeps a starred file a run found deleted: `config.yaml` goes on naming it among the favorites. `tests/screens/application/test_restart.py` reproduces it as a known failure. +* A folder asked for while a stopped read winds down is dropped without a word. Stop closes the scan + window at once while the walk runs on to its next entry, and `FolderScan.start` turns away the folder + asked for in that time, though it promises that a folder asked for once the window closes is read. + `tests/screens/main/test_scan.py` reproduces it as a known failure. +* A box clicked in the Converter list leaves Source settings where it stood: ticking a channel on a + recording inside an open folder changes that row, while the card goes on naming the row picked + before, or New recordings. `tests/screens/main/test_row_settings.py` reproduces it as a known failure. +* General settings stops short of the Converter's right edge while Advanced settings is put away, + where it should fill the row and end where the Converter below it does. + `tests/screens/main/test_cards.py` reproduces it as a known failure. +* The Destination line names no folder a run writes into once the gathered recordings convert with + different channels: it names the folder of every channel the rows use together, while each + recording goes into the folder of its own channels. `tests/screens/main/test_run.py` reproduces it as + a known failure. +* A library folder pointed away from and back lists its library unloaded: the library loaded before + reads as one that exists, where it should come back loaded. + `tests/screens/main/test_library.py` reproduces it as a known failure. diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py index 994735738..5d075630c 100644 --- a/tests/screens/conftest.py +++ b/tests/screens/conftest.py @@ -1,28 +1,34 @@ from tests.suite.screens.plugin import ( + conversion_hold, output_device, pytest_configure, pytest_pyfunc_call, pytest_runtest_protocol, pytest_unconfigure, + scan_hold, screen, screen_application, screen_boundaries, screen_bridge, + screen_holds, screen_render_thread, startup, world, ) __all__ = [ + "conversion_hold", "output_device", "pytest_configure", "pytest_pyfunc_call", "pytest_runtest_protocol", "pytest_unconfigure", + "scan_hold", "screen", "screen_application", "screen_boundaries", "screen_bridge", + "screen_holds", "screen_render_thread", "startup", "world", diff --git a/tests/screens/main/__init__.py b/tests/screens/main/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/test_cards.py b/tests/screens/main/test_cards.py new file mode 100644 index 000000000..6d463eb4d --- /dev/null +++ b/tests/screens/main/test_cards.py @@ -0,0 +1,163 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import ( + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_CONFIG_PANEL, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_SOURCE_PANEL, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import read_item, read_scroll +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.world import World, lived_in_world + +PAST_THE_DIALOG: Final[int] = 30 +SECONDS: Final[float] = 0.2 +FREQUENCY: Final[float] = 220.0 +SAME_LINE_PIXELS: Final[float] = 1.0 + + +def box(screen: Screen, tag: str) -> Rect: + rect = screen.bridge.ask(lambda: read_item(tag).rect) + assert rect is not None, tag + return rect + + +def bottom(rect: Rect) -> float: + return rect.y + rect.height + + +def right(rect: Rect) -> float: + return rect.x + rect.width + + +@pytest.fixture +def world() -> World: + state = lived_in_world().state + assert state is not None + files = tuple( + Recording(destination=home_path(f"voice{index:02d}.wav"), seconds=SECONDS, frequency=FREQUENCY) + for index in range(PAST_THE_DIALOG) + ) + return World( + state=state.model_copy(update={"advanced_settings": True}), + application_config=None, + config=None, + files=files, + ) + + +class TestTheCardsOfTheMainTab: + """The Main tab reads General settings, the Converter and Source settings, top to bottom, and the row of settings cards keeps its line.""" + + def test_the_order_and_the_shared_line(self, screen: Screen) -> None: + general = box(screen, TAG_MAIN_CONFIG_PANEL) + advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) + converter = box(screen, TAG_MAIN_CONVERTER_PANEL) + source = box(screen, TAG_MAIN_SOURCE_PANEL) + + assert general.y < converter.y < source.y + assert abs(bottom(general) - bottom(advanced)) <= SAME_LINE_PIXELS + assert right(general) < advanced.x + + def test_advanced_stands_beside_general_ending_where_the_converter_does(self, screen: Screen) -> None: + def advanced_off_and_on_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + + screen.expect(screen.main.advanced.is_shown, bool, description="Advanced back") + + def the_two_side_by_side(screen: Screen) -> None: + screen.expect( + lambda: right(box(screen, TAG_MAIN_CONFIG_PANEL)) < box(screen, TAG_MAIN_ADVANCED_PANEL).x, + bool, + description="the two side by side", + ) + advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) + assert abs(right(advanced) - right(box(screen, TAG_MAIN_CONVERTER_PANEL))) <= SAME_LINE_PIXELS + + screen.scenario(advanced_off_and_on_again, the_two_side_by_side).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: General settings stops short of the Converter's edge with Advanced put away", + ) + def test_general_fills_the_row_with_advanced_put_away(self, screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + + screen.expect( + lambda: abs(right(box(screen, TAG_MAIN_CONFIG_PANEL)) - right(box(screen, TAG_MAIN_CONVERTER_PANEL))), + lambda gap: gap <= SAME_LINE_PIXELS, + description="General ending where the Converter does", + ) + + def test_collapsing_general_alone_gives_up_the_rows_height(self, screen: Screen) -> None: + general = screen.main.card(TAG_MAIN_CONFIG_PANEL) + heights: List[float] = [] + + def put_advanced_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + heights.append(box(screen, TAG_MAIN_CONFIG_PANEL).height) + + def collapse_general(screen: Screen) -> None: + general.toggle() + + screen.expect(general.is_collapsed, bool, description="General collapsed") + screen.expect( + lambda: box(screen, TAG_MAIN_CONVERTER_PANEL).y - bottom(box(screen, TAG_MAIN_CONFIG_PANEL)), + lambda gap: gap < heights[0], + description="the Converter meeting the collapsed card", + ) + collapsed = box(screen, TAG_MAIN_CONFIG_PANEL) + assert collapsed.height < heights[0] + + screen.scenario(put_advanced_away, collapse_general).run() + + +class TestAddingAfterScrollingThePick: + """A mix picked from a scrolled list closes on Add, and the card answers the next gesture. + + The checklist asks it of twelve recordings; the question holds twelve without scrolling on this + screen, so the scenario gathers as many as make it scroll. + """ + + def test_the_question_closes_and_the_card_answers(self, screen: Screen) -> None: + main = screen.main + question = main.converter.mix_question + paths = [home_path(f"voice{index:02d}.wav") for index in range(PAST_THE_DIALOG)] + + def open_the_question_and_scroll_it(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + screen.expect( + lambda: screen.bridge.ask(lambda: read_scroll(question.tags.well)).maximum, + lambda maximum: maximum > 0, + description="the pick list scrolling", + ) + + screen.hand.scroll_to_end(question.tags.well) + + def add(screen: Screen) -> None: + question.add() + + screen.expect(question.is_shown, operator.not_, description="the question closed") + + def the_card_answers(screen: Screen) -> None: + main.converter.list.pick(paths[0]) + + screen.expect(main.source.subject, paths[0].stem.__eq__, description="the card on the row picked") + + screen.scenario(open_the_question_and_scroll_it, add, the_card_answers).run() diff --git a/tests/screens/main/test_gathering.py b/tests/screens/main/test_gathering.py new file mode 100644 index 000000000..e2ebf64a3 --- /dev/null +++ b/tests/screens/main/test_gathering.py @@ -0,0 +1,334 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_COLLAPSE_STRIP, + TAG_GLOBAL_THEME_COLLAPSE_HEADER, + TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED, +) +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_SOURCE_PANEL +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording, WrittenBytes +from tests.suite.screens.world import HomeFile, World, lived_in_world + +KICK: Final[str] = "kick.wav" +SNARE: Final[str] = "snare.wav" +TAKES: Final[str] = "Takes" +INNER: Final[str] = "Inner" +LOOPS: Final[str] = "Loops" +NOTES: Final[str] = "Notes" +TAKES_AT_THE_TOP: Final[Tuple[str, ...]] = ("one.wav", "two.wav", "three.wav") +TAKES_INSIDE: Final[Tuple[str, ...]] = ("four.wav", "five.wav") +LOOPS_HELD: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +COLLIDING: Final[Tuple[str, ...]] = ("Kick Loop.wav", "kick loop.wav", "ลผรณล‚w.wav") +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.2 +TONE_FREQUENCY: Final[float] = 220.0 +NOTE_TEXT: Final[bytes] = b"A folder holding words, not sounds.\n" +ROUNDS: Final[int] = 20 +FIRST_COUNTED_ROUND: Final[int] = 2 +POINTER_REACH: Final[int] = 4 + +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" +ADD_AS_STEM: Final[str] = "main.explorer.label.context_add_stem" +ADD_FOLDER: Final[str] = "main.explorer.label.context_add_folder_stems" +NOTHING_BELOW: Final[str] = "main.converter.message.scan_nothing_below" + + +def home(name: str) -> Path: + """A path in the home the application was started in.""" + return Path.cwd() / name + + +def recording(path: Path, seconds: float) -> HomeFile: + return Recording(destination=path, seconds=seconds, frequency=TONE_FREQUENCY) + + +def gathering_world() -> World: + files: List[HomeFile] = [ + recording(home(KICK), PLAYED_SECONDS), + recording(home(SNARE), PLAYED_SECONDS), + *(recording(home(TAKES) / name, SHORT_SECONDS) for name in TAKES_AT_THE_TOP), + *(recording(home(TAKES) / INNER / name, SHORT_SECONDS) for name in TAKES_INSIDE), + *(recording(home(LOOPS) / name, SHORT_SECONDS) for name in LOOPS_HELD), + *(recording(home(name), SHORT_SECONDS) for name in COLLIDING), + WrittenBytes(home(NOTES) / INNER / "readme.txt", NOTE_TEXT), + ] + world = lived_in_world() + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(files), + ) + + +def explorer_row(screen: Screen, path: Path) -> Item: + return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") + + +def gathered(screen: Screen, *paths: Path) -> None: + """Waits until the list holds a row for each of ``paths`` and nothing else.""" + converter = screen.main.converter + expected = sorted(converter.list.row(path) for path in paths) + screen.expect( + lambda: sorted(converter.list.rows()), + expected.__eq__, + description=f"the list holding {[path.name for path in paths]} alone", + ) + + +def folder_label(screen: Screen, path: Path, count: int) -> str: + return screen.words(FOLDER_ROW).format(name=path.name, count=count) + + +def wait_for_the_scan_to_end(screen: Screen) -> None: + screen.expect(lambda: not screen.main.converter.scan_shown(), bool, description="the folder read") + + +@pytest.fixture +def world() -> World: + return gathering_world() + + +class TestNavigatingGathersNothing: + """Opening folders in the explorer gathers nothing; a double-click on a recording gathers that one.""" + + def test_the_list_stays_empty_until_a_recording_is_double_clicked(self, screen: Screen) -> None: + converter = screen.main.converter + + def open_folders(screen: Screen) -> None: + for folder in (home(TAKES), home(TAKES) / INNER, home(LOOPS)): + row = explorer_row(screen, folder) + + screen.explorer.open_by_click(row) + + screen.expect(partial(screen.explorer.is_open, row), bool, description=f"{folder.name} open") + + def the_list_stays_empty(screen: Screen) -> None: + assert converter.list.hint_shown() + assert not converter.list.list_shown() + assert converter.action() == screen.words(CONVERT_NOTHING) + + def a_double_click_gathers_that_recording(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home(KICK))) + + gathered(screen, home(KICK)) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home(KICK).stem) + assert not converter.list.hint_shown() + + screen.scenario(open_folders, the_list_stays_empty, a_double_click_gathers_that_recording).run() + + +class TestAPlainClickOnARecording: + """A plain click plays a recording and gathers nothing; Ctrl-click and Add as stem gather it.""" + + def test_it_plays_and_the_gathering_gestures_gather(self, screen: Screen) -> None: + converter = screen.main.converter + playback = screen.sequencer.playback + + def a_click_plays(screen: Screen) -> None: + screen.explorer.click(explorer_row(screen, home(KICK))) + + screen.expect(playback.can_stop, bool, description="the recording playing") + assert converter.list.hint_shown() + + def ctrl_click_gathers(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(KICK))) + + gathered(screen, home(KICK)) + + def add_as_stem_gathers(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home(SNARE))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + + screen.context_menu.choose(screen.words(ADD_AS_STEM)) + + gathered(screen, home(KICK), home(SNARE)) + assert not screen.context_menu.is_shown() + + screen.scenario(a_click_plays, ctrl_click_gathers, add_as_stem_gathers).run() + + +class TestGatheringAFolder: + """A folder lands as one row counting its recordings, subfolders included, and starts no run.""" + + def test_ctrl_click_and_add_folder_each_give_one_counted_row(self, screen: Screen) -> None: + converter = screen.main.converter + + def gather_a_recording_first(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home(KICK))) + + gathered(screen, home(KICK)) + + def ctrl_click_a_folder(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(TAKES))) + + gathered(screen, home(KICK), home(TAKES)) + wait_for_the_scan_to_end(screen) + count = len(TAKES_AT_THE_TOP) + len(TAKES_INSIDE) + assert converter.list.label(home(TAKES)) == folder_label(screen, home(TAKES), count) + + def add_folder_from_its_menu(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home(LOOPS))) + screen.expect(screen.context_menu.is_shown, bool, description="the folder's menu") + + screen.context_menu.choose(screen.words(ADD_FOLDER)) + + gathered(screen, home(KICK), home(TAKES), home(LOOPS)) + wait_for_the_scan_to_end(screen) + assert converter.list.label(home(LOOPS)) == folder_label(screen, home(LOOPS), len(LOOPS_HELD)) + + def no_run_started_and_the_recording_stands_first(screen: Screen) -> None: + assert not converter.run_shown() + assert converter.action() != screen.words(CANCEL_RUN) + assert converter.list.rows()[0] == converter.list.row(home(KICK)) + + screen.scenario( + gather_a_recording_first, + ctrl_click_a_folder, + add_folder_from_its_menu, + no_run_started_and_the_recording_stands_first, + ).run() + + +class TestGatheringAtTheEdges: + """Gathering holds at its edges: a folder twice, a folder of no recordings, names that collide.""" + + def test_a_folder_gathered_twice_stands_once(self, screen: Screen) -> None: + for _ in range(2): + screen.explorer.ctrl_click(explorer_row(screen, home(TAKES))) + wait_for_the_scan_to_end(screen) + + gathered(screen, home(TAKES)) + + def test_a_folder_of_no_recordings_says_so_and_gathers_nothing(self, screen: Screen) -> None: + converter = screen.main.converter + notice = converter.nothing_below_notice + + def ctrl_click_a_folder_of_words(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(NOTES))) + + screen.expect(notice.is_shown, bool, description="the notice") + assert notice.words() == screen.words(NOTHING_BELOW) + + def dismiss_and_gather_a_folder_of_sounds(screen: Screen) -> None: + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + screen.explorer.ctrl_click(explorer_row(screen, home(LOOPS))) + + gathered(screen, home(LOOPS)) + + screen.scenario(ctrl_click_a_folder_of_words, dismiss_and_gather_a_folder_of_sounds).run() + + def test_names_colliding_in_case_and_spacing_stand_apart(self, screen: Screen) -> None: + converter = screen.main.converter + paths = [home(name) for name in COLLIDING] + + def gather_each(screen: Screen) -> None: + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + + gathered(screen, *paths) + + def a_box_on_one_leaves_the_others(screen: Screen) -> None: + first, *others = paths + before = [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] + + converter.list.tick(first, ChannelName.PULSE1) + + screen.expect( + lambda: converter.list.channel_ticked(first, ChannelName.PULSE1), + (not before[0]).__eq__, + description="the box flipped on the first", + ) + assert [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] == before + + screen.scenario(gather_each, a_box_on_one_leaves_the_others).run() + + +class TestRightClickingOverAndOver: + """Twenty right-clicks across the browser and the list each open a menu at the pointer, and build nothing lasting.""" + + def test_each_menu_stands_at_the_pointer_and_the_interface_does_not_grow(self, screen: Screen) -> None: + converter = screen.main.converter + menu = screen.context_menu + counts: List[int] = [] + + def gather(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home(KICK))) + gathered(screen, home(KICK)) + + def right_click_over_and_over(screen: Screen) -> None: + for round_number in range(1, ROUNDS + 1): + in_the_list = round_number % 2 == 0 + row = converter.list.row(home(KICK)) if in_the_list else explorer_row(screen, home(SNARE)) + if in_the_list: + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + else: + screen.explorer.right_click(row) + screen.expect(menu.is_shown, bool, description=f"the menu of round {round_number}") + pointer = screen.pointer() + + box = menu.box() + entries = menu.entries() + menu.dismiss() + + screen.expect(menu.is_shown, operator.not_, description=f"the menu of round {round_number} put away") + assert ( + box.overlap( + Rect( + x=pointer.x - POINTER_REACH, + y=pointer.y - POINTER_REACH, + width=2 * POINTER_REACH, + height=2 * POINTER_REACH, + ) + ) + is not None + ), (box, pointer) + assert entries and all(entry.enabled or entry.label for entry in entries) + if round_number >= FIRST_COUNTED_ROUND: + counts.append(screen.item_count()) + + def nothing_grew(screen: Screen) -> None: + assert counts[-1] == counts[0], counts + + screen.scenario(gather, right_click_over_and_over, nothing_grew).run() + + +class TestACardHeaderUnderThePointer: + """A card's header bar lights while the pointer rests on it and settles back once it leaves.""" + + def test_the_bar_lights_and_settles(self, screen: Screen) -> None: + strip = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_COLLAPSE_STRIP) + elsewhere = compose_tag(TAG_MAIN_SOURCE_PANEL, SUF_COLLAPSE_STRIP) + + def rest_on_the_bar(screen: Screen) -> None: + screen.hand.hover(strip) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED.__eq__, description="lit" + ) + + def move_off(screen: Screen) -> None: + screen.hand.scroll_into_view(elsewhere) + screen.hand.hover(elsewhere) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER.__eq__, description="settled" + ) + + screen.scenario(rest_on_the_bar, move_off).run() diff --git a/tests/screens/main/test_library.py b/tests/screens/main/test_library.py new file mode 100644 index 000000000..82b6bfbcc --- /dev/null +++ b/tests/screens/main/test_library.py @@ -0,0 +1,314 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_core.structures.tree.node import GeneratorNode +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items import Item, find_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import MiniLibrary, archived_library +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.world import World, one_worker_config, screen_filling_state + +QUICK_SAMPLE_RATE: Final[int] = 11025 +GENERATION_TIMEOUT_SECONDS: Final[float] = 300.0 +OTHER_LIBRARIES: Final[str] = "Other libraries" + +LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" +LIBRARY_MISSING: Final[str] = "instructions.library.template.library_not_exists_template" +GENERATE: Final[str] = "instructions.library.label.generate_library_button" +REGENERATE: Final[str] = "instructions.library.label.regenerate_library_button" +GENERATION_CANCELED: Final[str] = "instructions.library.message.status_generation_canceled" + + +def quick_config() -> Config: + """Settings whose library generates in seconds: a low sample rate, measured by the plain spectrum.""" + config = one_worker_config() + library = config.library.model_copy( + update={"sample_rate": QUICK_SAMPLE_RATE, "spectrum_method": SpectrumMethod.FFT} + ) + return config.model_copy(update={"library": library}) + + +def library_path(config: Config) -> Path: + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +def library_world(config: Config, *, built: bool) -> World: + state = screen_filling_state().model_copy(update={"advanced_settings": True}) + return World( + state=state, + application_config=None, + config=config, + files=(MiniLibrary(config),) if built else (), + ) + + +def library_row(screen: Screen, path: Path) -> Item: + library = screen.instructions.library + return screen.expect_item(partial(library.row, path.name), description=f"the row of {path.name}") + + +def open_library_row(screen: Screen, row: Item) -> None: + """Opens a library's row, which is the click that loads it, closing it first where it stands open.""" + tree = screen.instructions.library.tree + if tree.is_open(row): + tree.open_by_click(row) + screen.expect(partial(tree.is_open, row), operator.not_, description="the row closed") + + tree.open_by_click(row) + + +def generator_rows(screen: Screen, row: Item) -> List[Item]: + def read() -> List[Item]: + found: List[Item] = [] + find_item(row, lambda item: _collect_generator(item, found)) + return found + + return screen.bridge.ask(read) + + +def _collect_generator(item: Item, found: List[Item]) -> bool: + user_data = dpg.get_item_user_data(item) + if isinstance(user_data, tuple) and user_data and isinstance(user_data[0], GeneratorNode): + found.append(item) + + return False + + +class TestGeneratingALibrary: + """A configuration with no library generates one, which the tree lists and the status reads as loaded.""" + + @pytest.fixture + def world(self) -> World: + return library_world(quick_config(), built=False) + + def test_generate_lists_it_loaded_and_offers_regenerate(self, screen: Screen) -> None: + library = screen.instructions.library + path = library_path(quick_config()) + + def nothing_to_list(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered") + assert library.row(path.name) is None + + def generate(screen: Screen) -> None: + library.generate() + + row = screen.bridge.expect( + partial(library.row, path.name), + lambda found: found is not None, + description="the library listed", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert row is not None + name = library.tree.label(row) + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the library loaded", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert library.generate_label() == screen.words(REGENERATE) + assert path.exists() + + screen.scenario(nothing_to_list, generate).run() + + def test_cancelling_a_generation_leaves_no_library(self, screen: Screen) -> None: + library = screen.instructions.library + path = library_path(quick_config()) + + def start_and_cancel(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library.generate() + + library.cancel_generation() + + screen.bridge.expect( + library.notice.is_shown, bool, description="the cancel reported", timeout=GENERATION_TIMEOUT_SECONDS + ) + assert library.notice.words() == screen.words(GENERATION_CANCELED) + library.notice.dismiss() + screen.expect(library.notice.is_shown, operator.not_, description="the report dismissed") + + def nothing_was_written(screen: Screen) -> None: + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered again") + + assert not path.exists() + assert library.row(path.name) is None + assert library.status().endswith(screen.words(LIBRARY_MISSING).format("")) + + screen.scenario(start_and_cancel, nothing_was_written).run() + + +class TestRegeneratingALibrary: + """Regenerate asks first, and Cancel keeps the library as it stands.""" + + @pytest.fixture + def world(self) -> World: + return library_world(one_worker_config(), built=True) + + def test_cancel_keeps_it_and_the_next_press_asks_again(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.regenerate_prompt + path = library_path(one_worker_config()) + kept: List[bytes] = [] + + def regenerate_asks(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library_row(screen, path) + kept.append(path.read_bytes()) + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question") + + def cancel_keeps_it(screen: Screen) -> None: + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question asked again") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone again") + assert path.read_bytes() == kept[0] + + screen.scenario(regenerate_asks, cancel_keeps_it).run() + + +class TestAnotherLibraryFolder: + """Advanced settings pointed at another folder lists its libraries; pointed back, the first library returns loaded.""" + + @pytest.fixture + def world(self) -> World: + config = one_worker_config() + world = library_world(config, built=True) + return World( + state=world.state, + application_config=None, + config=config, + files=(*world.files, archived_library(home_path(OTHER_LIBRARIES))), + ) + + def test_each_folder_lists_its_own(self, screen: Screen) -> None: + library = screen.instructions.library + main = screen.main + ours = library_path(one_worker_config()) + names: List[str] = [] + + def ours_listed_and_loaded(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + row = library_row(screen, ours) + names.append(library.tree.label(row)) + + open_library_row(screen, row) + + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(names[0]).__eq__, description="loaded") + + def point_at_the_other_folder(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + screen.answer_next_dialog(DialogKind.DIRECTORY, home_path(OTHER_LIBRARIES)) + main.choose_library_directory() + screen.expect( + main.library_directory, str(home_path(OTHER_LIBRARIES)).__eq__, description="the other folder" + ) + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(lambda: library.row(ours.name) is None, bool, description="ours no longer listed") + assert library.tree.library_row(lambda node: node.outdated) is not None + + def point_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + screen.answer_next_dialog(DialogKind.DIRECTORY, LIBRARY_DIRECTORY) + main.choose_library_directory() + screen.expect(main.library_directory, str(LIBRARY_DIRECTORY).__eq__, description="our folder") + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + library_row(screen, ours) + assert library.tree.library_row(lambda node: node.outdated) is None + + screen.scenario(ours_listed_and_loaded, point_at_the_other_folder, point_back).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a library folder pointed away from and back lists its library unloaded", + ) + def test_pointing_back_finds_the_library_loaded(self, screen: Screen) -> None: + library = screen.instructions.library + main = screen.main + ours = library_path(one_worker_config()) + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + name = library.tree.label(library_row(screen, ours)) + open_library_row(screen, library_row(screen, ours)) + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(name).__eq__, description="loaded") + screen.tabs.bring_to_front(Tab.MAIN) + for folder in (home_path(OTHER_LIBRARIES), LIBRARY_DIRECTORY): + screen.answer_next_dialog(DialogKind.DIRECTORY, folder) + main.choose_library_directory() + screen.expect(main.library_directory, str(folder).__eq__, description=f"the folder {folder.name}") + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + library_row(screen, ours) + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(name).__eq__, description="loaded again") + + +class TestGeneratorsUnderALibrary: + """A library this build reads opens onto its generators; one another version built opens onto none.""" + + @pytest.fixture + def world(self) -> World: + config = one_worker_config() + world = library_world(config, built=True) + return World( + state=world.state, + application_config=None, + config=config, + files=(*world.files, archived_library(LIBRARY_DIRECTORY)), + ) + + def test_the_old_one_lists_none_and_ours_lists_them(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.rebuild_prompt + ours = library_path(one_worker_config()) + + def the_old_one_opens_onto_nothing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + old = screen.expect_item( + lambda: library.tree.library_row(lambda node: node.outdated), + description="the old library's row", + ) + + library.tree.open_by_click(old) + + screen.expect(prompt.is_shown, bool, description="the rebuild question") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + def ours_opens_onto_its_generators(screen: Screen) -> None: + open_library_row(screen, library_row(screen, ours)) + + screen.expect( + lambda: len(generator_rows(screen, library_row(screen, ours))), bool, description="generator rows" + ) + old = library.tree.library_row(lambda node: node.outdated) + assert old is not None + assert generator_rows(screen, old) == [] + + screen.scenario(the_old_one_opens_onto_nothing, ours_opens_onto_its_generators).run() diff --git a/tests/screens/main/test_list.py b/tests/screens/main/test_list.py new file mode 100644 index 000000000..c16bd4820 --- /dev/null +++ b/tests/screens/main/test_list.py @@ -0,0 +1,466 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.paths import LAYOUT_DIRECTORY +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_TEXT, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, + TAG_GLOBAL_THEME_STEMS_ROW, + TAG_GLOBAL_THEME_STEMS_ROW_INERT, +) +from sampletones_application.tags.main import PRE_MAIN_CONVERTER_STEMS +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.utils.serialization import load_yaml_model +from tests.suite.screens.dearpygui.items import Item, read_item, read_scroll +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.world import HomeFile, World, lived_in_world + +KICK: Final[str] = "kick.wav" +SNARE: Final[str] = "snare.wav" +FORTY: Final[str] = "Forty" +FORTY_COUNT: Final[int] = 40 +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.02 +FREQUENCY: Final[float] = 220.0 +STILL_FRAMES: Final[int] = 30 +SAME_LINE_PIXELS: Final[float] = 1.0 +HEADING: Final[str] = "heading" + +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +SHOW_RECORDINGS: Final[str] = "main.converter.label.context_open_folder" +HIDE_RECORDINGS: Final[str] = "main.converter.label.context_close_folder" +REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" +PLAY: Final[str] = "global.context.label.play" +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" +CHANNEL_NAMES: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: "global.context.label.pulse_1", + ChannelName.PULSE2: "global.context.label.pulse_2", + ChannelName.TRIANGLE: "global.context.label.triangle", + ChannelName.NOISE: "global.context.label.noise", +} + + +def home(name: str) -> Path: + return Path.cwd() / name + + +def take(index: int) -> str: + return f"take{index:02d}.wav" + + +def recording(path: Path, seconds: float) -> HomeFile: + return Recording(destination=path, seconds=seconds, frequency=FREQUENCY) + + +@pytest.fixture +def world() -> World: + files: List[HomeFile] = [ + recording(home(KICK), PLAYED_SECONDS), + recording(home(SNARE), PLAYED_SECONDS), + *(recording(home(FORTY) / take(index), SHORT_SECONDS) for index in range(FORTY_COUNT)), + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) + + +def explorer_row(screen: Screen, path: Path) -> Item: + return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer and waits for its row.""" + converter = screen.main.converter + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(partial(converter.list.has_row, path), bool, description=f"{path.name} gathered") + screen.expect(lambda: not converter.scan_shown(), bool, description="the read done") + + +def choose_from_the_row_menu(screen: Screen, path: Path, entry: str) -> None: + row = screen.main.converter.list.row(path) + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") + screen.context_menu.choose(entry) + + +class TestAFolderInTheList: + """A gathered folder arrives closed; its marker, a double-click on its name and its menu each open and close it.""" + + def test_each_gesture_opens_and_closes_it(self, screen: Screen) -> None: + listing = screen.main.converter.list + folder = home(FORTY) + + def arrives_closed(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + + def the_marker(screen: Screen) -> None: + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="open by its marker") + + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by its marker") + + def a_double_click_on_its_name(screen: Screen) -> None: + screen.hand.double_click(listing.row(folder)) + screen.expect(partial(listing.is_open, folder), bool, description="open by a double-click") + + screen.hand.double_click(listing.row(folder)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by a double-click") + + def its_menu(screen: Screen) -> None: + choose_from_the_row_menu(screen, folder, screen.words(SHOW_RECORDINGS)) + screen.expect(partial(listing.is_open, folder), bool, description="open from its menu") + + choose_from_the_row_menu(screen, folder, screen.words(HIDE_RECORDINGS)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed from its menu") + + screen.scenario(arrives_closed, the_marker, a_double_click_on_its_name, its_menu).run() + + +class TestAFolderThatScrolls: + """An open folder of forty scrolls in its own region, end to end, and its rows hold still under the pointer.""" + + def test_it_scrolls_on_its_own_to_its_last_row(self, screen: Screen) -> None: + listing = screen.main.converter.list + folder = home(FORTY) + region = listing.tags.region(str(folder)) + last = folder / take(FORTY_COUNT - 1) + + def open_it(screen: Screen) -> None: + gather(screen, home(KICK), folder) + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert screen.bridge.ask(lambda: read_scroll(region)).maximum > 0 + + def the_wheel_takes_it_to_its_last_row(screen: Screen) -> None: + well_before = screen.bridge.ask(lambda: read_scroll(listing.tags.well)) + + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + assert screen.bridge.ask(lambda: read_scroll(listing.tags.well)) == well_before + + def rows_hold_still_under_the_pointer(screen: Screen) -> None: + row = listing.row(last) + screen.hand.hover(row) + + with screen.record(lambda: read_item(row).rect) as recording: + screen.frames(STILL_FRAMES) + + assert len(set(recording.values())) == 1, recording.values() + + screen.scenario(open_it, the_wheel_takes_it_to_its_last_row, rows_hold_still_under_the_pointer).run() + + +class TestTheListComingAndGoing: + """With nothing gathered the hint stands alone; the first recording brings the list and removing the last takes it away.""" + + def test_the_hint_and_the_list_take_turns(self, screen: Screen) -> None: + listing = screen.main.converter.list + + def the_hint_alone(screen: Screen) -> None: + assert listing.hint_shown() + assert not listing.list_shown() + + def the_first_recording_brings_the_list(screen: Screen) -> None: + gather(screen, home(KICK)) + + assert listing.list_shown() + assert not listing.hint_shown() + + def removing_the_last_takes_it_away(screen: Screen) -> None: + listing.remove(home(KICK)) + + screen.expect(listing.hint_shown, bool, description="the hint back") + assert not listing.list_shown() + assert listing.rows() == [] + + screen.scenario(the_hint_alone, the_first_recording_brings_the_list, removing_the_last_takes_it_away).run() + + +class TestRemovingFolders: + """Removing a recording inside an open folder or the folder itself takes what it names and nothing else.""" + + def test_a_recording_inside_goes_alone_and_the_folder_counts_one_fewer(self, screen: Screen) -> None: + listing = screen.main.converter.list + folder = home(FORTY) + removed = folder / take(1) + + def open_the_folder_beside_a_recording(screen: Screen) -> None: + gather(screen, home(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + def remove_one_inside(screen: Screen) -> None: + outside = screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home(KICK)))) + + listing.remove(removed) + + screen.expect(partial(listing.has_row, removed), operator.not_, description="the recording gone") + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=FORTY_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="the folder counting one fewer") + assert listing.has_row(folder / take(0)) and listing.has_row(folder / take(2)) + assert listing.is_open(folder) + assert screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home(KICK)))) == outside + + screen.scenario(open_the_folder_beside_a_recording, remove_one_inside).run() + + def test_an_open_folder_goes_with_its_recordings_and_comes_back_closed(self, screen: Screen) -> None: + listing = screen.main.converter.list + folder = home(FORTY) + + def remove_it_open(screen: Screen) -> None: + gather(screen, home(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.remove(folder) + + screen.expect(partial(listing.has_row, folder), operator.not_, description="the folder gone") + assert listing.rows() == [listing.row(home(KICK))] + + def gather_it_again(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + assert listing.rows() == [listing.row(home(KICK)), listing.row(folder)] + + screen.scenario(remove_it_open, gather_it_again).run() + + +class TestUntickingEveryChannel: + """A recording with no channel left reads as out of play and leaves the run, while it stays listed.""" + + def test_the_row_dims_and_the_run_counts_without_it(self, screen: Screen) -> None: + listing = screen.main.converter.list + converter = screen.main.converter + + def gather_two(screen: Screen) -> None: + gather(screen, home(KICK), home(SNARE)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def untick_every_channel_of_one(screen: Screen) -> None: + for channel in ChannelName: + if listing.channel_ticked(home(KICK), channel): + listing.tick(home(KICK), channel) + screen.expect( + partial(listing.channel_ticked, home(KICK), channel), + operator.not_, + description=f"{channel} let go", + ) + + screen.expect( + partial(listing.row_theme, home(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW_INERT.__eq__, + description="the row out of play", + ) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home(SNARE).stem) + assert listing.has_row(home(KICK)) + + def one_channel_brings_it_back(screen: Screen) -> None: + listing.tick(home(KICK), ChannelName.PULSE1) + + screen.expect( + partial(listing.row_theme, home(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW.__eq__, + description="the row in play", + ) + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + screen.scenario(gather_two, untick_every_channel_of_one, one_channel_brings_it_back).run() + + +class TestPlayingFromTheList: + """A recording in the list plays at a double-click, and its menu leads with Play while its file stands.""" + + def test_a_double_click_plays_and_play_greys_once_the_file_is_gone(self, screen: Screen) -> None: + listing = screen.main.converter.list + menu = screen.context_menu + + def a_double_click_plays(screen: Screen) -> None: + gather(screen, home(KICK)) + + screen.hand.double_click(listing.row(home(KICK))) + + screen.expect(screen.sequencer.playback.can_stop, bool, description="the recording playing") + assert listing.rows() == [listing.row(home(KICK))] + + def its_menu_leads_with_play(screen: Screen) -> None: + screen.hand.right_click(listing.row(home(KICK))) + screen.expect(menu.is_shown, bool, description="the menu") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), True) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def play_greys_once_the_file_is_gone(screen: Screen) -> None: + home(KICK).unlink() + + screen.hand.right_click(listing.row(home(KICK))) + screen.expect(menu.is_shown, bool, description="the menu again") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), False) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away again") + + screen.scenario(a_double_click_plays, its_menu_leads_with_play, play_greys_once_the_file_is_gone).run() + + +class TestTheListsLook: + """The channel names above the list wear their channel's colour; a folder row carries the band and matches a recording's height.""" + + def test_names_bands_and_lines(self, screen: Screen) -> None: + listing = screen.main.converter.list + folder = home(FORTY) + + def gather_a_recording_and_a_folder(screen: Screen) -> None: + gather(screen, home(KICK), folder) + + def the_channel_names_wear_their_colours(screen: Screen) -> None: + for channel, key in CHANNEL_NAMES.items(): + name = compose_tag(PRE_MAIN_CONVERTER_STEMS, HEADING, channel, SUF_TEXT) + + assert screen.bridge.ask(partial(dpg.get_value, name)) == screen.words(key) + assert screen.theme_of(name) == CHANNEL_THEME_TAGS[channel] + + def the_folder_alone_carries_the_band(screen: Screen) -> None: + assert listing.band_theme(folder) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW + assert listing.band_theme(home(KICK)) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def rows_share_a_height_and_names_share_a_line_with_their_boxes(screen: Screen) -> None: + folder_box = screen.bridge.ask(lambda: read_item(listing.row(folder)).rect) + recording_box = screen.bridge.ask(lambda: read_item(listing.row(home(KICK))).rect) + check_box = screen.bridge.ask(lambda: read_item(listing.channel_box(home(KICK), ChannelName.PULSE1)).rect) + assert folder_box is not None and recording_box is not None and check_box is not None + + assert folder_box.height == recording_box.height + assert ( + abs((recording_box.y + recording_box.height / 2) - (check_box.y + check_box.height / 2)) + <= SAME_LINE_PIXELS + ) + + screen.scenario( + gather_a_recording_and_a_folder, + the_channel_names_wear_their_colours, + the_folder_alone_carries_the_band, + rows_share_a_height_and_names_share_a_line_with_their_boxes, + ).run() + + +THOUSANDS: Final[str] = "Thousands" +THOUSANDS_COUNT: Final[int] = 1500 +TINY_SECONDS: Final[float] = 0.01 +ONE_ROW: Final[int] = 1 +STEMS_LAYOUT: Final[Path] = LAYOUT_DIRECTORY / "general" / "stems.yaml" +MENU_ROW: Final[int] = 3 +WHEEL_PAST: Final[int] = 5 + + +def thousand(index: int) -> str: + return f"take{index:04d}.wav" + + +def rows_inside(screen: Screen, folder: Path) -> List[str]: + listing = screen.main.converter.list + inside = {listing.row(folder / thousand(index)) for index in range(THOUSANDS_COUNT)} + return [row for row in listing.rows() if row in inside] + + +class TestAFolderOfThousands: + """A folder of thousands opens drawing only the rows in view, and its last row and any menu still answer.""" + + @pytest.fixture + def world(self) -> World: + files = [ + Recording(destination=home(THOUSANDS) / thousand(index), seconds=TINY_SECONDS, frequency=FREQUENCY) + for index in range(THOUSANDS_COUNT) + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) + + def open_it(self, screen: Screen) -> None: + listing = screen.main.converter.list + gather(screen, home(THOUSANDS)) + listing.toggle_folder(home(THOUSANDS)) + screen.expect(partial(listing.is_open, home(THOUSANDS)), bool, description="the folder open") + + def test_it_draws_the_rows_in_view_and_reaches_its_last(self, screen: Screen) -> None: + listing = screen.main.converter.list + folder = home(THOUSANDS) + region = listing.tags.region(str(folder)) + last = folder / thousand(THOUSANDS_COUNT - 1) + + def opens_drawing_what_is_in_view(screen: Screen) -> None: + self.open_it(screen) + drawn = rows_inside(screen, folder) + region_box = screen.bridge.ask(lambda: read_item(region).rect) + first = screen.bridge.ask(lambda: read_item(drawn[0]).rect) + second = screen.bridge.ask(lambda: read_item(drawn[1]).rect) + assert region_box is not None and first is not None and second is not None + pitch = second.y - first.y + overscan = load_yaml_model(STEMS_LAYOUT, StemsListLayout).window_overscan + + assert len(drawn) <= int(region_box.height / pitch) + ONE_ROW + 2 * overscan + + def reaches_the_last_row(screen: Screen) -> None: + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(opens_drawing_what_is_in_view, reaches_the_last_row).run() + + def test_remove_chosen_after_the_wheel_turned_removes_the_row_the_menu_was_opened_on(self, screen: Screen) -> None: + listing = screen.main.converter.list + menu = screen.context_menu + folder = home(THOUSANDS) + region = listing.tags.region(str(folder)) + target = folder / thousand(MENU_ROW) + + def open_the_menu_and_turn_the_wheel(screen: Screen) -> None: + self.open_it(screen) + screen.hand.scroll_into_view(listing.row(target)) + screen.hand.right_click(listing.row(target)) + screen.expect(menu.is_shown, bool, description="the row's menu") + + screen.hand.turn_wheel_over(region, WHEEL_PAST) + + assert menu.is_shown() + + def remove_from_the_menu(screen: Screen) -> None: + menu.choose(screen.words(REMOVE_RECORDING)) + + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=THOUSANDS_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="one row fewer") + + def that_row_alone_went(screen: Screen) -> None: + screen.hand.turn_wheel_over(region, -2 * WHEEL_PAST) + screen.expect(partial(listing.has_row, folder / thousand(0)), bool, description="the top in view") + + assert not listing.has_row(target) + assert listing.has_row(folder / thousand(MENU_ROW - 1)) and listing.has_row(folder / thousand(MENU_ROW + 1)) + + screen.scenario(open_the_menu_and_turn_the_wheel, remove_from_the_menu, that_row_alone_went).run() diff --git a/tests/screens/main/test_mix.py b/tests/screens/main/test_mix.py new file mode 100644 index 000000000..e29f755e4 --- /dev/null +++ b/tests/screens/main/test_mix.py @@ -0,0 +1,202 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.world import World, lived_in_world + +GATHERED: Final[int] = MAX_STEM_SOURCES + 2 +SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 +NAMES_CLICKED: Final[int] = 4 + +COUNT_LINE: Final[str] = "main.converter.template.stem_selection_limit" +REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" +MOVES: Final[List[str]] = [ + "main.converter.label.context_move_up", + "main.converter.label.context_move_down", + "main.converter.label.context_join_above", + "main.converter.label.context_join_below", + "main.converter.label.context_isolate", +] + + +def recording(index: int) -> Path: + return home_path(f"voice{index:02d}.wav") + + +def recordings(count: int) -> List[Path]: + return [recording(index) for index in range(count)] + + +@pytest.fixture +def world() -> World: + files = tuple(Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) for path in recordings(GATHERED)) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + +def count_line(screen: Screen, picked: int, total: int) -> str: + return screen.words(COUNT_LINE).format(picked=picked, total=total, room=MAX_STEM_SOURCES) + + +class TestTurningToAMixPastItsRoom: + """Turning to a mix with more gathered than a mix holds asks which to take, with a full pick ticked.""" + + def test_the_question_ticks_a_full_pick_and_cancel_leaves_everything(self, screen: Screen) -> None: + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + + def turn_to_a_mix(screen: Screen) -> None: + gather(screen, *paths) + + main.choose_output(OutputKind.MIXED) + + screen.expect(question.is_shown, bool, description="the question") + picked, past = paths[:MAX_STEM_SOURCES], paths[MAX_STEM_SOURCES:] + assert all(question.is_ticked(path) for path in picked) + assert not any(question.is_ticked(path) or question.is_live(path) for path in past) + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES, GATHERED) + + def cancel_leaves_the_list_and_the_switch(screen: Screen) -> None: + question.cancel() + + screen.expect(question.is_shown, operator.not_, description="the question gone") + assert main.output() is OutputKind.PER_RECORDING + assert main.converter.list.rows() == [main.converter.list.row(path) for path in paths] + + def asking_again_asks_again(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(question.is_shown, bool, description="the question again") + question.cancel() + screen.expect(question.is_shown, operator.not_, description="the question gone again") + + screen.scenario(turn_to_a_mix, cancel_leaves_the_list_and_the_switch, asking_again_asks_again).run() + + def test_a_full_pick_swaps_one_for_one_and_add_waits_for_a_pick(self, screen: Screen) -> None: + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + ninth = paths[MAX_STEM_SOURCES] + + def open_the_question(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + + def letting_one_go_makes_the_ninth_live(screen: Screen) -> None: + assert not question.is_live(ninth) + + question.tick(paths[0]) + + screen.expect(partial(question.is_live, ninth), bool, description="the ninth live") + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES - 1, GATHERED) + question.tick(ninth) + screen.expect(partial(question.is_ticked, ninth), bool, description="the ninth picked") + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES, GATHERED) + assert not question.is_live(paths[0]) + + def add_waits_until_something_is_picked(screen: Screen) -> None: + for path in paths[1 : MAX_STEM_SOURCES + 1]: + question.tick(path) + screen.expect(question.can_add, operator.not_, description="Add waiting") + assert question.count_line() == count_line(screen, 0, GATHERED) + + question.tick(paths[0]) + + screen.expect(question.can_add, bool, description="Add answering") + + def add_makes_the_mix(screen: Screen) -> None: + question.add() + + screen.expect(question.is_shown, operator.not_, description="the question gone") + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the switch on a mix") + + screen.scenario( + open_the_question, + letting_one_go_makes_the_ninth_live, + add_waits_until_something_is_picked, + add_makes_the_mix, + ).run() + + def test_a_double_click_sounds_a_row_and_names_leave_no_highlight(self, screen: Screen) -> None: + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + + def open_the_question(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + + def a_double_click_sounds_it(screen: Screen) -> None: + screen.hand.double_click(question.name(paths[0])) + + screen.expect(screen.sequencer.playback.can_stop, bool, description="the recording sounding") + + def names_clicked_leave_no_highlight(screen: Screen) -> None: + before = [question.is_ticked(path) for path in paths] + for path in paths[1 : 1 + NAMES_CLICKED]: + screen.hand.scroll_into_view(question.name(path)) + screen.hand.click(question.name(path)) + + assert not any(question.is_highlighted(path) for path in paths) + assert [question.is_ticked(path) for path in paths] == before + question.cancel() + screen.expect(question.is_shown, operator.not_, description="the question gone") + + screen.scenario(open_the_question, a_double_click_sounds_it, names_clicked_leave_no_highlight).run() + + +class TestAMixOfOneAndOfTwo: + """A mix of one draws no level band and offers no moves; a second recording brings the bands; one per recording takes them away.""" + + def test_bands_come_with_the_second_recording_and_go_with_one_per_recording(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + first, second = recordings(2) + + def a_mix_of_one(screen: Screen) -> None: + gather(screen, first) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the switch on a mix") + + assert not listing.has_levels() + + def its_menu_offers_removal_and_no_moves(screen: Screen) -> None: + screen.hand.right_click(listing.row(first)) + screen.expect(menu.is_shown, bool, description="the menu") + + labels = menu.labels() + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert screen.words(REMOVE_RECORDING) in labels + assert not set(labels) & {screen.words(move) for move in MOVES} + + def the_second_brings_the_bands(screen: Screen) -> None: + gather(screen, second) + + screen.expect(listing.has_levels, bool, description="the level bands") + + def one_per_recording_takes_them_away(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + + screen.expect(listing.has_levels, operator.not_, description="the bands gone") + assert listing.rows() == [listing.row(first), listing.row(second)] + + screen.scenario( + a_mix_of_one, + its_menu_offers_removal_and_no_moves, + the_second_brings_the_bands, + one_per_recording_takes_them_away, + ).run() diff --git a/tests/screens/main/test_row_settings.py b/tests/screens/main/test_row_settings.py new file mode 100644 index 000000000..bf0ecd34f --- /dev/null +++ b/tests/screens/main/test_row_settings.py @@ -0,0 +1,419 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_INPUT_SEARCH +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_PANEL +from sampletones_application.ui.themes.channels import PARTIAL_CHANNEL_THEME_TAGS +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.holds import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.steps.main import choose_from_the_row_menu, gather, home_path +from tests.suite.screens.world import HomeFile, World, converting_world, lived_in_world + +KICK: Final[str] = "kick.wav" +SNARE: Final[str] = "snare.wav" +PAIR: Final[str] = "Pair" +PAIR_TAKES: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +PLAYED_SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 +FILTER_TEXT: Final[str] = "zz" +HELD_TIMEOUT_SECONDS: Final[float] = 60.0 + +NEW_RECORDINGS: Final[str] = "main.source.label.new_recordings" +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" +CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" +PROGRESS: Final[str] = "main.converter.template.progress_template" + + +def recordings() -> Tuple[HomeFile, ...]: + return ( + Recording(destination=home_path(KICK), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + Recording(destination=home_path(SNARE), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + *( + Recording(destination=home_path(PAIR) / name, seconds=PLAYED_SECONDS, frequency=FREQUENCY) + for name in PAIR_TAKES + ), + ) + + +@pytest.fixture +def world() -> World: + return World(state=lived_in_world().state, application_config=None, config=None, files=recordings()) + + +def ticked(screen: Screen, path: Path) -> Dict[ChannelName, bool]: + return {channel: screen.main.converter.list.channel_ticked(path, channel) for channel in ChannelName} + + +class TestTheCardFollowsThePick: + """Source settings names the row picked, recording or folder, and reads New recordings while none is.""" + + def test_it_names_each_pick_in_turn(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + + def nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(PAIR)) + + assert main.source.subject() == screen.words(NEW_RECORDINGS) + + def a_recording_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.expect(main.source.subject, home_path(KICK).stem.__eq__, description="the recording named") + assert listing.is_picked(home_path(KICK)) + assert {channel: main.source.channel_ticked(channel) for channel in ChannelName} == ticked( + screen, home_path(KICK) + ) + + def a_folder_picked(screen: Screen) -> None: + listing.pick(home_path(PAIR)) + + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the folder named") + assert not listing.is_picked(home_path(KICK)) + + screen.scenario(nothing_picked, a_recording_picked, a_folder_picked).run() + + +class TestAFoldersBoxes: + """A folder's box reads partial where its recordings disagree; one click settles them all and the next lets go.""" + + def test_a_box_inside_turns_the_folders_partial_and_the_folders_settles_them(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first, second = (folder / name for name in PAIR_TAKES) + channel = ChannelName.PULSE1 + + def open_the_folder(screen: Screen) -> None: + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def a_box_inside_changes_that_row_alone(screen: Screen) -> None: + listing.tick(first, channel) + + screen.expect(partial(listing.channel_ticked, first, channel), operator.not_, description="the box let go") + assert listing.channel_ticked(second, channel) + screen.expect( + partial(listing.channel_theme, folder, channel), + PARTIAL_CHANNEL_THEME_TAGS[channel].__eq__, + description="the folder's box partial", + ) + assert not listing.channel_ticked(folder, channel) + + def the_folders_box_settles_them_all(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), bool, description="the folder's box held") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def the_next_click_lets_them_go(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), operator.not_, description="let go") + assert not listing.channel_ticked(first, channel) and not listing.channel_ticked(second, channel) + + screen.scenario( + open_the_folder, + a_box_inside_changes_that_row_alone, + the_folders_box_settles_them_all, + the_next_click_lets_them_go, + ).run() + + +class TestTheCardFollowsABoxClicked: + """Ticking a box inside an open folder brings the card to that row, and the folder's own box to the folder.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a box clicked in the list leaves Source settings where it stood", + ) + def test_the_card_names_the_row_whose_box_was_clicked(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first = folder / PAIR_TAKES[0] + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.tick(first, ChannelName.PULSE1) + + screen.expect(main.source.subject, first.stem.__eq__, description="the card on the row clicked") + listing.tick(folder, ChannelName.PULSE1) + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the card on the folder") + + +class TestAChannelKey: + """A channel's key settles that channel on the row picked and no other; with nothing picked it changes nothing.""" + + def test_it_reaches_the_picked_row_alone(self, screen: Screen) -> None: + listing = screen.main.converter.list + before: List[Dict[ChannelName, bool]] = [] + + def press_with_nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + before.extend((ticked(screen, home_path(KICK)), ticked(screen, home_path(SNARE)))) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + def press_with_a_row_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped once") + assert ticked(screen, home_path(SNARE)) == before[1] + + screen.scenario(press_with_nothing_picked, press_with_a_row_picked).run() + + +class TestThePickHoldsThrough: + """The pick stands through a second click, a double-click that plays the row, and Esc stopping it.""" + + def test_clicks_double_clicks_and_esc_keep_the_pick(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + playback = screen.sequencer.playback + + def pick_twice(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + screen.expect(partial(listing.is_picked, home_path(KICK)), bool, description="picked") + + listing.pick(home_path(KICK)) + + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + def a_double_click_plays_and_keeps_it(screen: Screen) -> None: + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(playback.can_stop, bool, description="the recording playing") + assert listing.is_picked(home_path(KICK)) + + def esc_stops_and_keeps_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.can_stop, operator.not_, description="playback stopped") + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + screen.scenario(pick_twice, a_double_click_plays_and_keeps_it, esc_stops_and_keeps_it).run() + + +class TestTheKeysOfTheList: + """Del and the channel keys reach the picked row only while the Converter card is open and no field holds the keys.""" + + def test_a_collapsed_card_keeps_del_from_the_list(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + + def del_with_the_card_collapsed(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + card.toggle() + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def del_with_the_card_open(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.pick(home_path(SNARE)) + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(del_with_the_card_collapsed, del_with_the_card_open).run() + + def test_a_focused_field_takes_del_and_the_channel_keys(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + field = compose_tag(TAG_MAIN_EXPLORER_PANEL, SUF_INPUT_SEARCH) + before: List[Dict[ChannelName, bool]] = [] + + def keys_into_the_field(screen: Screen) -> None: + gather(screen, home_path(KICK)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + screen.hand.click(field) + screen.hand.type_text(FILTER_TEXT) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def the_same_key_reaches_the_row_once_the_field_lets_go(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(keys_into_the_field, the_same_key_reaches_the_row_once_the_field_lets_go).run() + + +class TestARightClickMovesThePick: + """A right-click on a row not picked picks it, and Del then removes that row.""" + + def test_del_after_a_right_click_removes_the_row_clicked(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + + def right_click_another_row(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + + screen.hand.right_click(listing.row(home_path(SNARE))) + + screen.expect(menu.is_shown, bool, description="the menu") + screen.expect(main.source.subject, home_path(SNARE).stem.__eq__, description="the card on the row clicked") + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def del_removes_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(right_click_another_row, del_removes_it).run() + + +class TestTheListDuringARun: + """While a run converts the list, Del, the menu's Remove and the channel keys leave it alone.""" + + @pytest.fixture + def world(self) -> World: + return converting_world(recordings()) + + def test_the_list_stands_as_it_was_through_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + converter = screen.main.converter + listing = converter.list + before: List[Dict[ChannelName, bool]] = [] + + def start_a_held_run(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + + converter.press_action() + + started = screen.words(PROGRESS).format(0, 2) + screen.bridge.expect( + converter.status, + lambda status: status.startswith(started), + description="the run under way on its first recording", + timeout=HELD_TIMEOUT_SECONDS, + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def the_keys_and_the_menu_reach_nothing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + assert not screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK)))).enabled + assert not screen.context_menu.is_shown() + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(home_path(SNARE))] + assert ticked(screen, home_path(KICK)) == before[0] + + def after_the_run_the_menu_and_del_reach_the_row(screen: Screen) -> None: + conversion_hold.release() + screen.bridge.expect( + converter.end_prompt.is_shown, + bool, + description="the run's end", + timeout=HELD_TIMEOUT_SECONDS, + ) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end prompt closed") + assert ticked(screen, home_path(KICK)) == before[0] + + choose_from_the_row_menu(screen, home_path(SNARE), screen.words(REMOVE_RECORDING)) + screen.expect( + partial(listing.has_row, home_path(SNARE)), operator.not_, description="removed from the menu" + ) + listing.pick(home_path(KICK)) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(KICK)), operator.not_, description="removed by Del") + + screen.scenario( + start_a_held_run, + the_keys_and_the_menu_reach_nothing, + after_the_run_the_menu_and_del_reach_the_row, + ).run() + + +HUNDREDS: Final[str] = "Hundreds" +HUNDREDS_COUNT: Final[int] = 300 +TINY_SECONDS: Final[float] = 0.01 + + +class TestHundredsGathered: + """Some three hundred recordings gathered: the card collapses and expands, the interface answers meanwhile, and the list scrolls end to end.""" + + @pytest.fixture + def world(self) -> World: + files = tuple( + Recording( + destination=home_path(HUNDREDS) / f"take{index:03d}.wav", seconds=TINY_SECONDS, frequency=FREQUENCY + ) + for index in range(HUNDREDS_COUNT) + ) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + def test_collapse_expand_and_scroll_end_to_end(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + folder = home_path(HUNDREDS) + last = folder / f"take{HUNDREDS_COUNT - 1:03d}.wav" + + def gather_and_collapse(screen: Screen) -> None: + gather(screen, folder) + card.toggle() + + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + def the_interface_answers_meanwhile(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + + def expand_and_scroll_end_to_end(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + screen.hand.scroll_to_end(listing.tags.region(str(folder))) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(gather_and_collapse, the_interface_answers_meanwhile, expand_and_scroll_end_to_end).run() diff --git a/tests/screens/main/test_run.py b/tests/screens/main/test_run.py new file mode 100644 index 000000000..539e332c7 --- /dev/null +++ b/tests/screens/main/test_run.py @@ -0,0 +1,423 @@ +import multiprocessing +import operator +import shutil +from functools import partial +from pathlib import Path +from typing import Dict, Final, FrozenSet, List, Tuple + +import pytest + +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_GROUP_INPUT, TAG_MAIN_CONVERTER_GROUP_ORDER +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.holds import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.steps.main import explorer_row, gather, home_path +from tests.suite.screens.world import HomeFile, World, converting_world + +SECONDS: Final[float] = 0.3 +FREQUENCY: Final[float] = 220.0 +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DRUMS: Final[str] = "drums.wav" +ALBUM: Final[str] = "Album" +DISC: Final[str] = "Disc2" +ALBUM_TAKES: Final[Tuple[str, ...]] = ("one.wav", "two.wav") +DISC_TAKES: Final[Tuple[str, ...]] = ("three.wav",) +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" +OTHER_RECONSTRUCTION: Final[str] = "other.stn" + +CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" +MIX_SEVERAL: Final[str] = "main.converter.template.mix_recordings" +CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" +RECONSTRUCT_FILE: Final[str] = "main.explorer.label.context_reconstruct_file" +RUN_FAILED: Final[str] = "main.converter.message.status_error" + + +def recording(path: Path) -> HomeFile: + return Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) + + +def run_world() -> World: + paths = [ + home_path(BASS), + home_path(LEAD), + home_path(DRUMS), + *(home_path(ALBUM) / name for name in ALBUM_TAKES), + *(home_path(ALBUM) / DISC / name for name in DISC_TAKES), + ] + return converting_world(tuple(recording(path) for path in paths)) + + +@pytest.fixture +def world() -> World: + return run_world() + + +def run_to_its_end(screen: Screen) -> None: + """Presses the button naming the run and waits for the question its end asks, which it closes.""" + converter = screen.main.converter + converter.press_action() + wait_for_the_end(screen) + + +def wait_for_the_end(screen: Screen) -> None: + converter = screen.main.converter + screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end closed") + + +def written(destination: Path) -> List[Path]: + return sorted(destination.rglob(f"*{RECONSTRUCTION_SUFFIX}")) + + +def converted_channels(path: Path) -> FrozenSet[ChannelName]: + """The channels a written reconstruction approximates, read off the file.""" + return frozenset(Reconstruction.load(path).approximations) + + +def modified(paths: List[Path]) -> Dict[Path, int]: + return {path: path.stat().st_mtime_ns for path in paths} + + +class TestTheButtonNamesTheRun: + """The button under the list names the run each time it changes.""" + + def test_one_several_a_mix_and_none(self, screen: Screen) -> None: + main = screen.main + converter = main.converter + + def one(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(BASS).stem) + + def several(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def a_mix(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(converter.action, screen.words(MIX_SEVERAL).format(count=2).__eq__, description="a mix named") + + def none(screen: Screen) -> None: + converter.list.remove(home_path(BASS)) + converter.list.remove(home_path(LEAD)) + + screen.expect(converter.action, screen.words(CONVERT_NOTHING).__eq__, description="nothing to name") + + screen.scenario(one, several, a_mix, none).run() + + +class TestTheLinesOfTheCard: + """Order joins only from a mix's second recording, Destination always stands, and Input only while a run reads.""" + + def test_order_and_destination(self, screen: Screen) -> None: + main = screen.main + converter = main.converter + + def order_waits_for_a_second_recording_in_a_mix(screen: Screen) -> None: + assert converter.destination() + gather(screen, home_path(BASS)) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown + + def the_second_brings_it(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown, + bool, + description="Order joining", + ) + assert converter.destination() + + screen.scenario(order_waits_for_a_second_recording_in_a_mix, the_second_brings_it).run() + + def test_input_stands_while_a_run_reads(self, screen: Screen, conversion_hold: ConversionHold) -> None: + converter = screen.main.converter + + def no_input_before_the_run(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + def input_while_it_reads(screen: Screen) -> None: + converter.press_action() + + screen.bridge.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown, + bool, + description="the Input line", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def no_input_after_it(screen: Screen) -> None: + conversion_hold.release() + wait_for_the_end(screen) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + screen.scenario(no_input_before_the_run, input_while_it_reads, no_input_after_it).run() + + +def give_each_its_own_channel(screen: Screen, channels: Dict[Path, ChannelName]) -> None: + """Leaves each gathered recording the one channel ``channels`` names for it.""" + listing = screen.main.converter.list + for path, keep in channels.items(): + for channel in ChannelName: + if listing.channel_ticked(path, channel) != (channel == keep): + listing.tick(path, channel) + screen.expect( + partial(listing.channel_ticked, path, channel), + (channel == keep).__eq__, + description=f"{path.name} {channel}", + ) + + +def written_as(path: Path) -> Path: + """The one reconstruction a run wrote for the recording at ``path``, wherever below the folder it went.""" + found = [file for file in written(RECONSTRUCTIONS_DIRECTORY) if file.stem == path.stem] + assert len(found) == 1, found + return found[0] + + +class TestWhereARunWrites: + """A run writes each recording with the channels its row gave, where the Destination line says.""" + + def channels(self) -> Dict[Path, ChannelName]: + return {home_path(BASS): ChannelName.TRIANGLE, home_path(LEAD): ChannelName.PULSE1} + + def test_each_recording_carries_the_channels_its_row_gave(self, screen: Screen) -> None: + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + + run_to_its_end(screen) + + for path, channel in self.channels().items(): + assert converted_channels(written_as(path)) == {channel} + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: Destination names no folder a run of differing channels writes into", + ) + def test_the_destination_line_names_the_folder_the_run_writes_into(self, screen: Screen) -> None: + converter = screen.main.converter + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + destination = Path(converter.destination()) + + run_to_its_end(screen) + + assert all(written_as(path).parent == destination for path in self.channels()) + + +class TestAFolderConverts: + """A listed folder converts into a tree mirroring it; a rerun leaves what was written and makes the rest.""" + + def test_a_mirrored_tree_then_only_what_is_missing(self, screen: Screen) -> None: + converter = screen.main.converter + folder = home_path(ALBUM) + expected = [Path(name).with_suffix(RECONSTRUCTION_SUFFIX) for name in ALBUM_TAKES] + [ + (Path(DISC) / name).with_suffix(RECONSTRUCTION_SUFFIX) for name in DISC_TAKES + ] + kept: List[Dict[Path, int]] = [] + destinations: List[Path] = [] + + def convert_the_folder(screen: Screen) -> None: + gather(screen, folder) + destinations.append(Path(converter.destination())) + + run_to_its_end(screen) + + files = written(destinations[0]) + assert len(files) == len(expected) + assert all(any(file.as_posix().endswith(part.as_posix()) for file in files) for part in expected) + + def rerun_makes_only_what_is_missing(screen: Screen) -> None: + files = written(destinations[0]) + missing, *standing = files + missing.unlink() + kept.append(modified(standing)) + + run_to_its_end(screen) + + assert written(destinations[0]) == files + assert modified(standing) == kept[0] + + screen.scenario(convert_the_folder, rerun_makes_only_what_is_missing).run() + + +class TestReplacingWhatARunWrote: + """Recordings gathered by name and converted before ask first, and Convert anyway replaces exactly them.""" + + def test_cancel_writes_nothing_and_convert_anyway_replaces_those_alone(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.overwrite_prompt + paths = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] + state: Dict[str, Dict[Path, int]] = {} + destinations: List[Path] = [] + + def convert_three_and_set_another_beside_them(screen: Screen) -> None: + gather(screen, *paths) + destinations.append(Path(converter.destination())) + run_to_its_end(screen) + first = written(destinations[0])[0] + shutil.copyfile(first, destinations[0] / OTHER_RECONSTRUCTION) + state["before"] = modified(written(destinations[0])) + + def cancel_writes_nothing(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about replacing") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert modified(written(destinations[0])) == state["before"] + + def convert_anyway_replaces_those_alone(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + after = modified(written(destinations[0])) + other = destinations[0] / OTHER_RECONSTRUCTION + assert after[other] == state["before"][other] + assert all( + after[file] != state["before"][file] + for file in (destinations[0] / f"{path.stem}{RECONSTRUCTION_SUFFIX}" for path in paths) + ) + + screen.scenario( + convert_three_and_set_another_beside_them, + cancel_writes_nothing, + convert_anyway_replaces_those_alone, + ).run() + + +class TestReconstructingAFileWhileAListStands: + """Reconstruct file with a list gathered asks first: Keep the list converts nothing, Replace it converts that file alone.""" + + def test_keep_the_list_then_replace_it(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.replace_prompt + destinations: List[Path] = [] + + def ask_from_the_browser(screen: Screen) -> None: + gather(screen, home_path(BASS), home_path(LEAD)) + destinations.append(Path(converter.destination())) + screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) + + screen.expect(prompt.is_shown, bool, description="the question about the list") + + def keep_the_list(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert converter.list.rows() == [converter.list.row(home_path(BASS)), converter.list.row(home_path(LEAD))] + assert written(destinations[0]) == [] + + def replace_it(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) + screen.expect(screen.context_menu.is_shown, bool, description="the menu again") + screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + assert [file.stem for file in written(destinations[0])] == [home_path(DRUMS).stem] + + screen.scenario(ask_from_the_browser, keep_the_list, replace_it).run() + + +class TestAHeldRun: + """A run shows its progress, Continue lets it run on, and Stop ends it with nothing written and no worker left.""" + + @pytest.mark.usefixtures("conversion_hold") + def test_progress_continue_and_stop(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.cancel_prompt + destinations: List[Path] = [] + + def start_it(screen: Screen) -> None: + gather(screen, home_path(BASS)) + destinations.append(Path(converter.destination())) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def continue_lets_it_run(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about stopping") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert converter.action() == screen.words(CANCEL_RUN) + + def stop_ends_it(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.bridge.expect( + lambda: len(multiprocessing.active_children()), + operator.not_, + description="no worker left", + timeout=RUN_TIMEOUT_SECONDS, + ) + assert written(destinations[0]) == [] + + screen.scenario(start_it, continue_lets_it_run, stop_ends_it).run() + + +class TestARecordingGoneBeforeTheRun: + """A recording deleted after it was gathered is reported in words naming it when the run reaches it.""" + + def test_the_run_says_what_went_wrong(self, screen: Screen) -> None: + converter = screen.main.converter + notice = screen.error_notice + gather(screen, home_path(BASS)) + home_path(BASS).unlink() + + converter.press_action() + + screen.bridge.expect(notice.is_shown, bool, description="the failure reported", timeout=RUN_TIMEOUT_SECONDS) + words = notice.words() + screen.claim_error(home_path(BASS).name) + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the report dismissed") + assert words.startswith(screen.words(RUN_FAILED)) + assert home_path(BASS).name in words + assert converter.status() == screen.words(RUN_FAILED) diff --git a/tests/screens/main/test_scan.py b/tests/screens/main/test_scan.py new file mode 100644 index 000000000..a1edd9cc7 --- /dev/null +++ b/tests/screens/main/test_scan.py @@ -0,0 +1,186 @@ +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.holds import Holds, ReleaseSignal, ScanHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.world import HomeFile, World, lived_in_world + +MANY: Final[str] = "Many" +FEW: Final[str] = "Few" +MANY_RECORDINGS: Final[int] = 130 +FEW_RECORDINGS: Final[int] = 2 +REPORTED_COUNT: Final[int] = 128 +SECONDS: Final[float] = 0.05 +FREQUENCY: Final[float] = 330.0 +SLOW_ENTRY_SECONDS: Final[float] = 2.0 +SLOW_RELEASE_FILE: Final[str] = "release-slow-scan" + +SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" + + +def home(name: str) -> Path: + return Path.cwd() / name + + +def folder_of(name: str, count: int) -> List[HomeFile]: + return [ + Recording(destination=home(name) / f"take{index:03d}.wav", seconds=SECONDS, frequency=FREQUENCY) + for index in range(count) + ] + + +@pytest.fixture +def world() -> World: + lived_in = lived_in_world() + return World( + state=lived_in.state, + application_config=None, + config=None, + files=(*folder_of(MANY, MANY_RECORDINGS), *folder_of(FEW, FEW_RECORDINGS)), + ) + + +def explorer_row(screen: Screen, path: Path) -> Item: + return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") + + +def folder_label(screen: Screen, name: str, count: int) -> str: + return screen.words(FOLDER_ROW).format(name=name, count=count) + + +class TestReadingALargeFolder: + """A folder being read shows a window naming it and counting what it found, while the rest answers.""" + + def test_the_window_counts_and_the_interface_answers_meanwhile(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def start_reading(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + + expected = screen.words(SCAN_PROGRESS).format(count=REPORTED_COUNT, name=MANY) + screen.expect(converter.scan_words, expected.__eq__, description="the count of the folder read so far") + assert converter.scan_shown() + + def the_interface_answers(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + assert converter.scan_shown() + assert converter.list.hint_shown() + + def the_read_lands(screen: Screen) -> None: + scan_hold.release() + + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + screen.expect( + lambda: converter.list.has_row(home(MANY)), + bool, + description="the folder gathered", + ) + assert converter.list.label(home(MANY)) == folder_label(screen, MANY, MANY_RECORDINGS) + + screen.scenario(start_reading, the_interface_answers, the_read_lands).run() + + +class TestStoppingARead: + """Stop gives the read up and gathers nothing; the next Ctrl-click reads afresh.""" + + def test_stop_gathers_nothing_and_the_next_read_runs(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def stop_a_read(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + + converter.stop_scan() + + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + def the_next_read_runs(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(FEW))) + + screen.expect(converter.scan_shown, bool, description="a new read under way") + scan_hold.release() + screen.expect(lambda: converter.list.has_row(home(FEW)), bool, description="the second folder gathered") + assert converter.list.rows() == [converter.list.row(home(FEW))] + + screen.scenario(stop_a_read, the_next_read_runs).run() + + +class TestAskingForAFolderDuringARead: + """A folder asked for while another is being read is turned away, and asking again once the window closes reads it.""" + + def test_the_read_under_way_runs_on_and_the_folder_asked_for_reads_afterwards( + self, + screen: Screen, + scan_hold: ScanHold, + ) -> None: + converter = screen.main.converter + + def ask_for_a_second_folder_during_a_read(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + + screen.explorer.right_click(explorer_row(screen, home(FEW))) + screen.expect(screen.context_menu.is_shown, bool, description="the second folder's menu") + screen.context_menu.choose(screen.words(RECONSTRUCT_DIRECTORY)) + + screen.expect(screen.context_menu.is_shown, operator.not_, description="the menu put away") + assert converter.scan_shown() + assert MANY in converter.scan_words() + + def the_first_read_lands(screen: Screen) -> None: + scan_hold.release() + + screen.expect(lambda: converter.list.has_row(home(MANY)), bool, description="the first folder gathered") + assert not converter.run_shown() + + def asking_again_reads_it(screen: Screen) -> None: + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + screen.explorer.ctrl_click(explorer_row(screen, home(FEW))) + + screen.expect(lambda: converter.list.has_row(home(FEW)), bool, description="the second folder gathered") + + screen.scenario( + ask_for_a_second_folder_during_a_read, + the_first_read_lands, + asking_again_reads_it, + ).run() + + +class TestAskingForAFolderAsAStoppedReadWindsDown: + """A folder asked for once Stop closed the window is read, as the scan promises once its window closes.""" + + @pytest.fixture + def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> ScanHold: + hold = ScanHold(ReleaseSignal(Path.cwd().parent / SLOW_RELEASE_FILE), interval=SLOW_ENTRY_SECONDS) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a folder asked for while a stopped read winds down is dropped", + ) + def test_the_folder_asked_for_is_read(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + converter.stop_scan() + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + screen.explorer.ctrl_click(explorer_row(screen, home(FEW))) + + scan_hold.release() + screen.expect(lambda: converter.list.has_row(home(FEW)), bool, description="the folder asked for gathered") diff --git a/tests/suite/library.py b/tests/suite/library.py index 524514300..7fe821c50 100644 --- a/tests/suite/library.py +++ b/tests/suite/library.py @@ -1,10 +1,17 @@ from pathlib import Path -from typing import Final +from typing import Final, Tuple from sampletones_application.config.managers.config import ConfigManager from sampletones_application.view_model.main.updates import AdvancedSettingsUpdate from sampletones_core.configs import Config -from sampletones_core.library import InstructionLibraryData +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import get_generators_by_channels +from sampletones_core.generators.maps import GENERATOR_TO_INSTRUCTION_MAP +from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from sampletones_core.library.creator.creation import generate_instruction +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_tools.corpus.catalog import CHANNELS, build_mini_library OTHER_LIBRARIES: Final[str] = "other_libraries" @@ -34,3 +41,33 @@ def aim_library_directory(config_manager: ConfigManager, directory: Path) -> Non reconstructions_directory=config_manager.get_reconstructions_directory(), ) ) + + +def build_served_library(config: Config) -> Tuple[InstructionLibrary, InstructionLibraryKey]: + """The mini library for ``config``, holding each generator's default instruction beside its samples. + + A converter run matches against any library, while the Instructions tab opens a generator on + its default instruction, so a library the interface browses holds that one too. + + Returns: + Tuple[InstructionLibrary, InstructionLibraryKey]: The library, and the key it keeps the data under. + """ + library = build_mini_library(config) + window = Window.from_config(config) + key = library.create_key(config, window) + extractor = get_feature_extractor(config, window) + generators = { + generator.class_name(): generator for generator in get_generators_by_channels(config, CHANNELS).values() + } + data = dict(library.data[key].data) + data.update( + generate_instruction( + generators, + class_name, + GENERATOR_TO_INSTRUCTION_MAP[type(generator)].default_instruction(), + extractor, + ) + for class_name, generator in generators.items() + ) + library.data[key] = InstructionLibraryData.create(config, data) + return library, key diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application.py index 71924b9bc..f5d7be077 100644 --- a/tests/suite/screens/application.py +++ b/tests/suite/screens/application.py @@ -24,6 +24,7 @@ from tests.suite.screens.dearpygui.bridge import Bridge, RenderThreadStoppedError from tests.suite.screens.dearpygui.hosting import SCENARIO_THREAD_NAME from tests.suite.screens.dearpygui.screenshot import capture +from tests.suite.screens.holds import Holds from tests.suite.screens.keyboard import primary_combination from tests.suite.screens.render_thread import QueueRenderThread @@ -56,6 +57,7 @@ class Boundaries: dialogs: ScriptedFileDialogs errors: ErrorRecords spawns: SpawnGuard + holds: Holds class ScreenApplication: @@ -64,7 +66,7 @@ class ScreenApplication: A scenario that ends well leaves through the Exit shortcut, so every question the application asks about unsaved work runs as it would for a user, and the application must then stop at once. A scenario that failed, or a screen the after-checks object to, stops the loop outright once a - screenshot of it is kept. + screenshot of it is kept. Either way, the work the scenario held is let go first. """ def __init__( @@ -102,6 +104,7 @@ def run(self) -> None: self._render_thread.stop() def finish(self, *, failed: bool) -> None: + self._boundaries.holds.release_all() findings = self._boundary_findings() if self._render_thread.is_running(): findings.extend(self._screen_findings()) diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index e17c620b1..95ce5dd43 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -1,4 +1,5 @@ import time +from contextlib import suppress from functools import partial from typing import Callable, Dict, Final, Optional, Sequence @@ -11,6 +12,7 @@ enclosing_regions, read_hovered, read_item, + read_region_view, read_scroll, read_viewport, read_visible_box, @@ -35,9 +37,13 @@ HOVER_ARRIVAL_FRAMES: Final[int] = 5 REACH_TIMEOUT_SECONDS: Final[float] = 10.0 WHEEL_FRAMES: Final[int] = 1 -SCROLL_NOTCH_LIMIT: Final[int] = 400 +SCROLL_NOTCH_LIMIT: Final[int] = 100 EDGE_INSET: Final[int] = 3 +IMGUI_DOUBLE_CLICK_SECONDS: Final[float] = 0.30 DRAG_STEPS: Final[int] = 8 +GRIP_MARGIN_PIXELS: Final[float] = 8.0 +GRIP_DRAGS: Final[int] = 4 +IMGUI_GRAB_MIN_SIZE: Final[float] = 12.0 IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { MouseButton.LEFT: dpg.mvMouseButton_Left, MouseButton.RIGHT: dpg.mvMouseButton_Right, @@ -71,11 +77,34 @@ def __init__( ) -> None: self._bridge = bridge self._device = device + self._last_release: Optional[float] = None def click(self, item: Item) -> None: """Presses ``item`` with the left button.""" self._press(item, MouseButton.LEFT, SINGLE_PRESS) + def click_holding( + self, + item: Item, + modifiers: Sequence[int], + ) -> None: + """Presses ``item`` with the left button while holding the Dear ImGui keys in ``modifiers``. + + The keys go down a frame before the button and come up once the press has settled, which is + how a person makes a Ctrl-click, so an application reading them while it answers the press + finds them held. + """ + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + self._settle(SETTLE_FRAMES) + def double_click(self, item: Item) -> None: """Presses ``item`` twice in quick succession with the left button.""" self._press(item, MouseButton.LEFT, DOUBLE_PRESS) @@ -84,15 +113,38 @@ def right_click(self, item: Item) -> None: """Presses ``item`` with the right button, the gesture that opens a context menu.""" self._press(item, MouseButton.RIGHT, SINGLE_PRESS) + def click_at(self, point: Point) -> None: + """Presses the left button at ``point`` of the viewport, for a target DearPyGui reports a position alone for. + + The press confirms it reached the application, while what it landed on is for the scenario + to read from what the press changed. + """ + viewport = self._bridge.ask(read_viewport) + self._device.move(Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y)) + self._settle(HOVER_FRAMES) + self._device.button_down(MouseButton.LEFT) + self._settle(HOLD_FRAMES) + self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") + self._device.button_up(MouseButton.LEFT) + self._settle(SETTLE_FRAMES) + def hover(self, item: Item) -> None: """Rests the pointer over ``item`` until the item reports it. Raises: GestureLostError: If the item never reports the pointer resting on it. """ - self._device.move(self._aim(item)) - self._bridge.frames(HOVER_FRAMES) - self._confirm_hover(item) + deadline = time.monotonic() + REACH_TIMEOUT_SECONDS + while True: + self._device.move(self._aim(item)) + self._bridge.frames(HOVER_FRAMES) + if self._hovered_within(item, HOVER_ARRIVAL_FRAMES): + return + if time.monotonic() >= deadline: + raise GestureLostError( + f"The pointer was aimed at {item!r} and the item reports no hover: " + f"something covers it, or the region it scrolls in clips it" + ) def wheel( self, @@ -104,6 +156,50 @@ def wheel( self._turn_wheel(notches) self._settle(SETTLE_FRAMES) + def turn_wheel_over( + self, + region: Item, + notches: int, + ) -> None: + """Turns the wheel ``notches`` notches over the right edge of ``region``, down for a positive count. + + The pointer goes where it goes whatever stands open, so the gesture reports nothing; the + scenario reads what the turn moved. + """ + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def scroll_to_end(self, region: Item) -> None: + """Drags the grip of ``region``'s scrollbar to the bottom of its track, as a person reaches the end of a long list. + + A list drawing only the rows in view settles its length as it scrolls, so the grip is dragged + again while the end moves on, and a turn of the wheel takes up the pixels the grip's whole + steps leave over. + + Raises: + UnreachableError: If the region stands short of its end afterwards. + """ + self.scroll_into_view(region) + for _ in range(GRIP_DRAGS): + before = self._bridge.ask(lambda: read_scroll(region)) + if before.position >= before.maximum: + return + + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.maximum + GRIP_MARGIN_PIXELS)) + self._drag_between(start, end) + self._settle(SETTLE_FRAMES) + + short = self._bridge.ask(lambda: read_scroll(region)) + if short.position < short.maximum and short.by_wheel: + self.turn_wheel_over(region, 1) + + after = self._bridge.ask(lambda: read_scroll(region)) + if after.position < after.maximum: + raise UnreachableError(f"The region {region!r} stands short of its end at {after}") + def scroll_into_view(self, item: Item) -> None: """Turns the wheel over the regions ``item`` scrolls in until the item stands whole in view. @@ -111,7 +207,8 @@ def scroll_into_view(self, item: Item) -> None: after. The wheel moves a region that takes it: the first notch shows how far one notch carries the region, and each later turn takes as many notches as the distance left needs, the way a person spins the wheel toward a row. A region the wheel leaves alone is moved by dragging its - scrollbar's grip to where the grip stands for the scroll the item needs. + scrollbar's grip to where the grip stands for the scroll the item needs, and a few pixels on, + since the grip moves in whole pixels and each of them scrolls further than one. Raises: UnreachableError: If a region stops moving, or the wheel runs out of turns, first. @@ -135,8 +232,9 @@ def _scroll_within(self, item: Item, region: Item) -> None: notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 self._turn_wheel(notches if distance > 0 else -notches) else: - start = self._bridge.ask(lambda: _grip_point(region, before.position)) - end = self._bridge.ask(lambda: _grip_point(region, before.position + distance)) + margin = GRIP_MARGIN_PIXELS if distance > 0 else -GRIP_MARGIN_PIXELS + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.position + distance + margin)) self._drag_between(start, end) notches = 1 @@ -204,6 +302,7 @@ def _press( count: int, ) -> None: self.hover(item) + self._outlast_a_double_click() imgui_button = IMGUI_MOUSE_BUTTONS[button] for _ in range(count): self._device.button_down(button) @@ -212,8 +311,29 @@ def _press( self._device.button_up(button) self._settle(RELEASE_FRAMES) + self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) + def _outlast_a_double_click(self) -> None: + """Waits until the last press lies further back than a double-click reaches, in the application's clock. + + A person's next gesture comes later than that, while frames drawn quickly bring it within + reach, where Dear ImGui counts it with the clicks before it. + """ + last = self._last_release + if last is None: + return + + while float(self._bridge.ask(dpg.get_total_time)) - last < IMGUI_DOUBLE_CLICK_SECONDS: + self._bridge.frames(ONE_FRAME) + + def _application_seconds(self) -> Optional[float]: + """The application's clock, or ``None`` once a gesture has closed the application.""" + try: + return float(self._bridge.ask(dpg.get_total_time)) + except RenderThreadStoppedError: + return None + def _drag_between(self, start: Point, end: Point) -> None: """Presses the left button at ``start``, carries it to ``end`` over a few frames, and lets go there.""" self._device.move(start) @@ -246,22 +366,19 @@ def _tap(self, keysym: int) -> None: self._device.key_up(keysym) self._settle(RELEASE_FRAMES) - def _confirm_hover(self, item: Item) -> None: - """Waits a few frames for ``item`` to report the pointer resting on it, where its kind reports hover. + def _hovered_within(self, item: Item, frames: int) -> bool: + """Whether ``item`` reports the pointer resting on it within ``frames`` frames, or reports no hover at all. - Raises: - GestureLostError: If the item never reports the pointer, which is a press bound elsewhere. + An item standing in a tree that rebuilds reports no hover until the rebuild lets it go, which + is why the hover is aimed again until the reach time runs out. """ - for _ in range(HOVER_ARRIVAL_FRAMES): + for _ in range(frames): if self._bridge.ask(lambda: read_hovered(item)) is not False: - return + return True self._bridge.frames(ONE_FRAME) - raise GestureLostError( - f"The pointer was aimed at {item!r} and the item reports no hover: " - f"something covers it, or the region it scrolls in clips it" - ) + return False def _confirm_keys(self, keys: Sequence[int]) -> None: for key in keys: @@ -287,10 +404,8 @@ def _confirm( def _settle(self, count: int) -> None: """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" - try: + with suppress(RenderThreadStoppedError): self._bridge.frames(count) - except RenderThreadStoppedError: - return def _aim(self, item: Item) -> Point: """The screen pixel at the middle of ``item``, once a user could press it and it holds still. @@ -334,7 +449,7 @@ def _distance_from_view(item: Item, region: Item) -> float: Runs on the render thread. """ item_box = read_item(item).rect - region_box = read_item(region).rect + region_box = read_region_view(region) if item_box is None or region_box is None: raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") @@ -368,19 +483,21 @@ def _wheel_point(region: Item) -> Point: def _grip_point(region: Item, scroll: float) -> Point: """The pixel at the middle of ``region``'s scrollbar grip while the region stands scrolled to ``scroll``. - The grip spans the share of the content the region shows, and travels the rest of the track as - the region scrolls from its top to its end. Runs on the render thread. + The grip spans the share of the content the region shows, held at Dear ImGui's smallest grip, + and travels the rest of the track as the region scrolls from its top to its end. The track spans + the region's whole height, so the region stands whole in view for its grip to be dragged. Runs on + the render thread. """ - visible = read_visible_box(region) - if visible is None: - raise UnreachableError(f"No part of the region {region!r} stands in view to drag its scrollbar") + box = read_item(region).rect + if box is None: + raise UnreachableError(f"The region {region!r} reports no box to drag its scrollbar in") reading = read_scroll(region) target = min(max(scroll, 0.0), reading.maximum) - grip = visible.height * visible.height / (visible.height + reading.maximum) - travel = visible.height - grip + grip = min(max(box.height * box.height / (box.height + reading.maximum), IMGUI_GRAB_MIN_SIZE), box.height) + travel = box.height - grip viewport = read_viewport() return Point( - x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, - y=round(viewport.y + visible.y + grip / 2 + travel * target / reading.maximum), + x=round(viewport.x + box.x + box.width) - EDGE_INSET, + y=round(viewport.y + box.y + grip / 2 + travel * target / reading.maximum), ) diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py index a3d6d2e7e..e298342aa 100644 --- a/tests/suite/screens/dearpygui/items.py +++ b/tests/suite/screens/dearpygui/items.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg -from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.geometry import Point, Rect Item = Union[int, str] @@ -138,15 +138,36 @@ def enclosing_regions(item: Item) -> Tuple[Item, ...]: return tuple(regions) +def read_region_view(region: Item) -> Optional[Rect]: + """The part of ``region``'s box its content shows through, its scrollbars and far padding left out. + + Runs on the render thread. A region reports the room left for its content, which is its box + less its padding on both sides and the scrollbars standing in it, so the view keeps the near + side whole and gives up half of what the region withholds on the far side, which covers its + scrollbar. + """ + box = read_item(region).rect + if box is None: + return None + + room = dpg.get_item_state(region)["content_region_avail"] + return Rect( + x=box.x, + y=box.y, + width=box.width - (box.width - room[0]) / 2, + height=box.height - (box.height - room[1]) / 2, + ) + + def read_visible_box(item: Item) -> Optional[Rect]: """The part of ``item``'s box the regions around it and the viewport leave in view. Runs on the render thread.""" box = read_item(item).rect for region in enclosing_regions(item): - region_box = read_item(region).rect - if box is None or region_box is None: + view = read_region_view(region) + if box is None or view is None: return None - box = box.overlap(region_box) + box = box.overlap(view) return box.overlap(read_client_area()) if box is not None else None @@ -180,6 +201,44 @@ def find_item( return None +def read_item_count() -> int: + """How many items the context holds, which grows with what the interface builds. Runs on the render thread.""" + return len(dpg.get_all_items()) + + +@dataclass(frozen=True) +class EntryReading: + """One entry of a popup menu: its words, where it stands in the viewport, and whether it answers.""" + + label: str + point: Point + enabled: bool + + +def read_popup_entries(popup: Item) -> Tuple[EntryReading, ...]: + """The menu entries a popup window holds, in order. Runs on the render thread. + + DearPyGui reports an entry's position inside the popup alone, so the entry stands where the + popup does, moved by that position. + """ + corner = dpg.get_item_pos(popup) + entries: List[EntryReading] = [] + for child in dpg.get_item_children(popup, 1): + if dpg.get_item_info(child)["type"] != MENU_ITEM_TYPE: + continue + + position = dpg.get_item_state(child)["pos"] + entries.append( + EntryReading( + label=read_label(child), + point=Point(x=round(corner[0] + position[0]), y=round(corner[1] + position[1])), + enabled=bool(dpg.get_item_configuration(child).get("enabled", True)), + ) + ) + + return tuple(entries) + + def read_hovered(item: Item) -> Optional[bool]: """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. @@ -289,6 +348,12 @@ def read_viewport_title() -> str: return str(dpg.get_viewport_title()) +def read_pointer() -> Point: + """Where the pointer stands in the viewport, as the application last saw it. Runs on the render thread.""" + position = dpg.get_mouse_pos(local=False) + return Point(x=round(position[0]), y=round(position[1])) + + def read_client_area() -> Rect: """The viewport's client area in the coordinates its items report. Runs on the render thread.""" return Rect( diff --git a/tests/suite/screens/dearpygui/recording.py b/tests/suite/screens/dearpygui/recording.py new file mode 100644 index 000000000..a34c6c24c --- /dev/null +++ b/tests/suite/screens/dearpygui/recording.py @@ -0,0 +1,63 @@ +import threading +from dataclasses import dataclass +from typing import Callable, Final, Generic, List, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, RenderThread + +ReadingT = TypeVar("ReadingT") + +NO_FRAME: Final[int] = 0 + + +@dataclass(frozen=True) +class FrameReading(Generic[ReadingT]): + """What one reading found right after one frame was drawn.""" + + frame: int + value: ReadingT + + +class FrameRecording(Generic[ReadingT]): + """A reading taken right after every frame between a start and a stop, on the render thread. + + An expectation reads once a frame until it holds, and so misses a state lasting a single frame. + A recording keeps every frame's reading, so a scenario asserting that something never happened + between two gestures reads each frame that passed. + """ + + def __init__( + self, + render_thread: RenderThread, + reading: Callable[[], ReadingT], + ) -> None: + self._render_thread = render_thread + self._reading = reading + self._readings: List[FrameReading[ReadingT]] = [] + self._stopped = threading.Event() + self._lock = threading.Lock() + + def start(self) -> None: + self._render_thread.post(self._take, frames=ONE_FRAME) + + def stop(self) -> None: + self._stopped.set() + + @property + def readings(self) -> Tuple[FrameReading[ReadingT], ...]: + with self._lock: + return tuple(self._readings) + + def values(self) -> List[ReadingT]: + return [reading.value for reading in self.readings] + + def _take(self) -> None: + if self._stopped.is_set(): + return + + reading = FrameReading(frame=dpg.get_frame_count(), value=self._reading()) + with self._lock: + self._readings.append(reading) + + self._render_thread.post(self._take, frames=ONE_FRAME) diff --git a/tests/suite/screens/holds.py b/tests/suite/screens/holds.py new file mode 100644 index 000000000..26816d74a --- /dev/null +++ b/tests/suite/screens/holds.py @@ -0,0 +1,162 @@ +import time +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import AbstractSet, Final, Iterator, List, Protocol, Sequence + +import pytest + +import sampletones_application.logic.main.sources.scan as scan_module +import sampletones_core.reconstructions.converter.converter as converter_module +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.progress import STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, announce +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.types.path import Pathlike +from tests.suite.conversion import COUNTED_STAGES, FAKE_FRAMES, HALFWAY + +RELEASE_POLL_SECONDS: Final[float] = 0.05 +HELD_ENTRY: Final[str] = "held-entry.txt" + + +class Hold(Protocol): + """Work a scenario keeps under way until it lets go, so a gesture lands while the work runs.""" + + def release(self) -> None: + """Lets the work carry on to its end.""" + + +class Holds: + """Every hold a scenario put on the application's work, let go together before the scenario leaves. + + Leaving waits for work in flight, so the holds are released before the exit is asked for. + """ + + def __init__(self) -> None: + self._holds: List[Hold] = [] + + def add(self, hold: Hold) -> None: + self._holds.append(hold) + + def release_all(self) -> None: + for hold in self._holds: + hold.release() + + +@dataclass(frozen=True) +class ReleaseSignal: + """A file whose appearance lets held work carry on, which a worker process sees as well as the scenario.""" + + path: Path + + def release(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.touch() + + def is_released(self) -> bool: + return self.path.exists() + + +class HeldReconstructor: + """A reconstructor that walks a run's stages, stops halfway through matching until released, and builds nothing. + + It decides when the run lands, never what it computes: the walk reports the stages a real + reconstruction reports, and a run left with nothing built writes no file. While held it goes on + announcing the halfway mark, which is where a run the reader stops unwinds, and it waits for as + long as the scenario holds it. + + It travels to a worker with the job, the way the real one does, so it is built from a + configuration and the channels the same way, with the release named beside them. + """ + + def __init__( + self, + config: Config, + channels: AbstractSet[ChannelName], + release_path: Path, + ) -> None: + self.config = config + self.channels = channels + self.release_path = release_path + + def reconstruct( + self, + paths: Sequence[Pathlike], + stems_config: StemsConfig, + *, + report: ReconstructionReporter, + ) -> None: + """Walks the stages of a reconstruction, holding halfway through matching, and answers with nothing built. + + Raises: + OperationCanceled: If the run is withdrawn while it is under way. + """ + del paths, stems_config + for stage in ReconstructionStage: + if stage not in COUNTED_STAGES: + announce(report, stage, STAGE_BEGUN, WHOLE_STAGE) + continue + + for frame in range(FAKE_FRAMES + 1): + announce(report, stage, frame, FAKE_FRAMES) + if stage == ReconstructionStage.MATCHING and frame == HALFWAY: + self._hold(report, stage, frame) + + def _hold( + self, + report: ReconstructionReporter, + stage: ReconstructionStage, + frame: int, + ) -> None: + while not self.release_path.exists(): + announce(report, stage, frame, FAKE_FRAMES) + time.sleep(RELEASE_POLL_SECONDS) + + +class ConversionHold: + """Stands in for the converter's reconstructor with one that holds every run halfway until released.""" + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + converter_module, + "Reconstructor", + partial(HeldReconstructor, release_path=self._signal.path), + ) + + def release(self) -> None: + self._signal.release() + + +class ScanHold: + """Stands in for the walk a folder scan reads, holding every scan once the tree is read until released. + + The walk meets every entry the folder holds, and then goes on meeting an entry that is no + recording, one every ``interval`` seconds, as a tree of thousands of other files would, until the + scenario lets it end. A reader's Stop is checked between entries, so it is heard while the scan + is held, as soon as the next entry comes. + """ + + def __init__( + self, + signal: ReleaseSignal, + *, + interval: float, + ) -> None: + self._signal = signal + self._interval = interval + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + yield from root.rglob("*") + while not self._signal.is_released(): + yield root / HELD_ENTRY + time.sleep(self._interval) diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index e426604ad..5e4a033d1 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -26,6 +26,7 @@ child_environment, display_backend, ) +from tests.suite.screens.holds import ConversionHold, Holds, ReleaseSignal, ScanHold from tests.suite.screens.paths import FAILURE_SCREENSHOT, SCREENS_DIRECTORY from tests.suite.screens.render_thread import QueueRenderThread from tests.suite.screens.screen import Screen @@ -35,6 +36,9 @@ ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" +CONVERSION_RELEASE_FILE: Final[str] = "release-conversion" +SCAN_RELEASE_FILE: Final[str] = "release-scan" +SCAN_ENTRY_SECONDS: Final[float] = 0.05 SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() @@ -151,9 +155,43 @@ def screen_bridge(screen_render_thread: QueueRenderThread) -> Bridge: return Bridge(screen_render_thread, answer_timeout=ANSWER_TIMEOUT_SECONDS) +@pytest.fixture +def screen_holds() -> Holds: + """The work the scenario holds under way, let go before it leaves.""" + return Holds() + + +@pytest.fixture +def conversion_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ConversionHold: + """Holds every conversion the scenario starts halfway through matching, until the scenario releases it.""" + hold = ConversionHold(ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / CONVERSION_RELEASE_FILE)) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def scan_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ScanHold: + """Holds every folder scan the scenario starts once its tree is read, until the scenario releases it.""" + hold = ScanHold( + ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / SCAN_RELEASE_FILE), + interval=SCAN_ENTRY_SECONDS, + ) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + @pytest.fixture def screen_boundaries( output_device: OutputDevice, + screen_holds: Holds, monkeypatch: pytest.MonkeyPatch, ) -> Boundaries: """Puts the scenario's stand-ins between the application and the desktop before it starts.""" @@ -172,6 +210,7 @@ def screen_boundaries( dialogs=dialogs, errors=errors, spawns=spawns, + holds=screen_holds, ) diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 8c318d360..19adb82f2 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -1,5 +1,6 @@ +from contextlib import contextmanager from pathlib import Path -from typing import Callable, Final, Optional, Tuple, TypeVar, Union +from typing import Callable, Final, Iterator, Optional, Tuple, TypeVar, Union from sampletones_application.categories.key.text import TextKeyTuple from sampletones_application.categories.manager import LanguageManager @@ -15,13 +16,24 @@ from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords from tests.suite.screens.dearpygui.bridge import Bridge, ExpectationError +from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, WindowReading, read_viewport_title, read_windows +from tests.suite.screens.dearpygui.items import ( + Item, + WindowReading, + read_item_count, + read_pointer, + read_theme, + read_viewport_title, + read_windows, +) +from tests.suite.screens.dearpygui.recording import FrameRecording from tests.suite.screens.dearpygui.screenshot import capture from tests.suite.screens.dearpygui.windows import WindowManager from tests.suite.screens.keyboard import press_combination, primary_combination from tests.suite.screens.render_thread import QueueRenderThread from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.context_menu import ContextMenu from tests.suite.screens.views.display_settings import DisplaySettings from tests.suite.screens.views.instructions import Instructions from tests.suite.screens.views.main import Main @@ -75,6 +87,7 @@ def __init__( self.display_settings = DisplaySettings(bridge, hand, self.menu) self.project = Project(bridge, hand, self.menu) self.main = Main(bridge, hand, language) + self.context_menu = ContextMenu(bridge, hand) self.explorer = FileTree(bridge, hand, TAG_MAIN_EXPLORER_TREE) self.reconstructions = Reconstructions(bridge, hand, self.menu) self.sequencer = Sequencer(bridge, self.menu) @@ -118,6 +131,31 @@ def expect_item( return found + def pointer(self) -> Point: + """Where the pointer stands in the viewport, as the application last saw it.""" + return self.bridge.ask(read_pointer) + + def theme_of(self, item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, which is how a control wears a state such as hover.""" + return self.bridge.ask(lambda: read_theme(item)) + + def item_count(self) -> int: + """How many items the interface holds, which a gesture repeated without end leaves where it stood.""" + return self.bridge.ask(read_item_count) + + @contextmanager + def record(self, reading: Callable[[], ReadingT]) -> Iterator[FrameRecording[ReadingT]]: + """Takes ``reading`` on the render thread after every frame drawn while the block runs. + + ``reading`` runs between frames on the render thread, so it reads DearPyGui directly. + """ + recording = FrameRecording(self._render_thread, reading) + recording.start() + try: + yield recording + finally: + recording.stop() + def frames(self, count: int) -> None: """Lets ``count`` frames pass, for a scenario whose promise is itself a number of frames.""" self.bridge.frames(count) diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py index 9c5d24c05..311ba228d 100644 --- a/tests/suite/screens/seeds.py +++ b/tests/suite/screens/seeds.py @@ -8,10 +8,15 @@ import soundfile from sampletones_core.compatibility.kind import ObjectKind -from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document +from tests.suite.library import build_served_library FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" TRUNCATED_FRACTION: Final[int] = 2 +CONFIG_FIELD: Final[str] = "config" RECORDING_SAMPLE_RATE: Final[int] = 44100 RECORDING_AMPLITUDE: Final[float] = 0.5 @@ -64,6 +69,20 @@ def write(self) -> None: soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE) +@dataclass(frozen=True) +class MiniLibrary: + """A small library built for ``config``, saved where the application looks for that configuration's library. + + A conversion matches against it in seconds, and the Instructions tab browses it. + """ + + config: Config + + def write(self) -> None: + library, key = build_served_library(self.config) + library.save_data(key, library.data[key]) + + def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at ``destination``.""" return CopiedFile( @@ -72,6 +91,14 @@ def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: ) +def archived_library(folder: Path) -> CopiedFile: + """The library a release built, laid in ``folder`` under the name its settings give a library file.""" + source = archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY]) + config = Config(library=InstructionsLibraryConfig.model_validate(stored_document(source)[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return CopiedFile(source=source, destination=folder / key.filename) + + def damaged_document( kind: ObjectKind, damage: Damage, diff --git a/tests/suite/screens/steps/main.py b/tests/suite/screens/steps/main.py new file mode 100644 index 000000000..42204842c --- /dev/null +++ b/tests/suite/screens/steps/main.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.screen import Screen + + +def home_path(name: str) -> Path: + """A path in the home the application was started in, which the explorer stands open on.""" + return Path.cwd() / name + + +def explorer_row(screen: Screen, path: Path) -> Item: + """The explorer's row of ``path``, once it is drawn.""" + return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer, waiting for the button to name the larger run. + + A long list draws only the rows in view, so the button counting the run is what shows a + recording joined it. + """ + converter = screen.main.converter + for path in paths: + before = converter.action() + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(converter.action, before.__ne__, description=f"{path.name} gathered") + screen.expect(lambda: not converter.scan_shown(), bool, description="the folder read") + + +def choose_from_the_row_menu(screen: Screen, path: Path, entry: str) -> None: + """Right-clicks the converter's row of ``path`` and chooses ``entry`` from its menu.""" + row = screen.main.converter.list.row(path) + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") + screen.context_menu.choose(entry) diff --git a/tests/suite/screens/views/browsers.py b/tests/suite/screens/views/browsers.py index e62d3b82f..15bb50501 100644 --- a/tests/suite/screens/views/browsers.py +++ b/tests/suite/screens/views/browsers.py @@ -7,6 +7,7 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import TREE_NODE_TYPE, Item, find_item, read_label +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_CTRL NodeTest = Callable[[TreeNode], bool] @@ -67,11 +68,26 @@ def open_by_click(self, row: Item) -> None: self._hand.scroll_into_view(row) self._hand.click(row) + def click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + def double_click(self, row: Item) -> None: """Brings ``row`` into view and double-clicks it.""" self._hand.scroll_into_view(row) self._hand.double_click(row) + def ctrl_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it while holding Ctrl.""" + self._hand.scroll_into_view(row) + self._hand.click_holding(row, [IMGUI_LEFT_CTRL]) + + def right_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it with the right button, which opens its menu.""" + self._hand.scroll_into_view(row) + self._hand.right_click(row) + def _row(self, matches: NodeTest) -> Optional[Item]: return self._bridge.ask( lambda: find_item( diff --git a/tests/suite/screens/views/context_menu.py b/tests/suite/screens/views/context_menu.py new file mode 100644 index 000000000..fb972004e --- /dev/null +++ b/tests/suite/screens/views/context_menu.py @@ -0,0 +1,69 @@ +from typing import Final, List + +from sampletones_application.tags.general import TAG_GLOBAL_CONTEXT_WINDOW, TAG_GLOBAL_TAB_INSTRUCTIONS +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point, Rect +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import EntryReading, read_item, read_popup_entries + +ENTRY_INSET: Final[Point] = Point(x=12, y=8) +CLEAR_OF_THE_TABS: Final[int] = 48 + + +class MissingEntryError(AssertionError): + """Raised when a scenario chooses an entry the open menu does not offer.""" + + +class ContextMenu: + """The menu a right-click opens, shared by every list and tree, with its entries in the order shown. + + An entry is chosen by a click where it stands, which closes the menu the way a person's click + does, and the menu is put away by a click beside it, on the bare stretch of the tab bar past the + last tab. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).shown + + def box(self) -> Rect: + """Where the menu stands in the viewport.""" + rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).rect + if rect is None: + raise MissingEntryError("The context menu stands nowhere") + + return rect + + def entries(self) -> List[EntryReading]: + return list(self._bridge.ask(lambda: read_popup_entries(TAG_GLOBAL_CONTEXT_WINDOW))) + + def labels(self) -> List[str]: + return [entry.label for entry in self.entries()] + + def choose(self, label: str) -> None: + """Clicks the entry reading ``label``. + + Raises: + MissingEntryError: If the menu offers no such entry. + """ + for entry in self.entries(): + if entry.label == label: + self._hand.click_at(Point(x=entry.point.x + ENTRY_INSET.x, y=entry.point.y + ENTRY_INSET.y)) + return + + raise MissingEntryError(f"The context menu offers no '{label}': it offers {self.labels()}") + + def dismiss(self) -> None: + """Clicks the bare stretch of the tab bar past the last tab, which puts the menu away.""" + rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS)).rect + if rect is None: + raise MissingEntryError("The last tab stands nowhere to click past it") + + self._hand.click_at(Point(x=round(rect.x + rect.width) + CLEAR_OF_THE_TABS, y=rect.center.y)) diff --git a/tests/suite/screens/views/converter.py b/tests/suite/screens/views/converter.py new file mode 100644 index 000000000..e08a01c52 --- /dev/null +++ b/tests/suite/screens/views/converter.py @@ -0,0 +1,264 @@ +from pathlib import Path +from typing import Final, List, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_DIALOG_INFO, SUF_TOOLTIP +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + PRE_MAIN_CONVERTER_STEMS, + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, + TAG_MAIN_CONVERTER_DIALOG_LOAD, + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, + TAG_MAIN_CONVERTER_GROUP, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_CONVERTER_PROGRESS, + TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_TEXT_STATUS, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_SCAN, + TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, + TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, +) +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import Item, find_item, read_item, read_label, read_texts, read_theme +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.views.prompts import Prompt + +ROW_TEXT: Final[str] = "text" +ROW_GROUP: Final[str] = "group" +ROW_TWISTY: Final[str] = "twisty" +ROW_REMOVE: Final[str] = "button" +ROW_MARK: Final[str] = ".row." +FIRST_LEVEL: Final[int] = 0 +LEVEL_STRIP: Final[str] = "strip" + + +class ConverterList: + """The recordings and folders gathered into the Converter card, one row each. + + A row is named by the path it was gathered from, so a scenario addresses the row of the file + it seeded. A folder's row carries a marker that opens it onto its recordings. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_STEMS) + + def row(self, path: Path) -> str: + """The name of the row gathered from ``path``, which a click picks.""" + return self.tags.row(str(path), ROW_TEXT) + + def has_row(self, path: Path) -> bool: + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.row(path)))) + + def label(self, path: Path) -> str: + return self._bridge.ask(lambda: read_label(self.row(path))) + + def is_picked(self, path: Path) -> bool: + return bool(self._bridge.ask(lambda: dpg.get_value(self.row(path)))) + + def row_theme(self, path: Path) -> Optional[str]: + return self._bridge.ask(lambda: read_theme(self.row(path))) + + def band_theme(self, path: Path) -> Optional[str]: + return self._bridge.ask(lambda: read_theme(self.tags.row(str(path), ROW_GROUP))) + + def rows(self) -> List[str]: + """The names of every row drawn, top to bottom, as the tags they stand under.""" + + def read() -> List[str]: + found: List[str] = [] + find_item(self.tags.well, lambda item: _collect_row(item, found)) + return found + + return self._bridge.ask(read) + + def row_count(self) -> int: + return len(self.rows()) + + def channel_box(self, path: Path, channel: ChannelName) -> str: + return self.tags.channel(str(path), channel) + + def channel_ticked(self, path: Path, channel: ChannelName) -> bool: + return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(path, channel)))) + + def channel_theme(self, path: Path, channel: ChannelName) -> Optional[str]: + return self._bridge.ask(lambda: read_theme(self.channel_box(path, channel))) + + def twisty(self, path: Path) -> str: + return self.tags.row(str(path), ROW_TWISTY) + + def is_open(self, path: Path) -> bool: + """Whether the folder gathered from ``path`` stands open onto its recordings.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.region(str(path))))) + + def remove_button(self, path: Path) -> str: + return self.tags.row(str(path), ROW_REMOVE) + + def has_levels(self) -> bool: + """Whether the list stands in level bands, which a mix of several recordings draws.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.level(FIRST_LEVEL, LEVEL_STRIP)))) + + def hint_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT)).shown + + def list_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(self.tags.well)).shown + + def pick(self, path: Path) -> None: + """Brings the row into view and clicks its name.""" + self._hand.scroll_into_view(self.row(path)) + self._hand.click(self.row(path)) + + def tick(self, path: Path, channel: ChannelName) -> None: + """Brings the row into view and clicks its box for ``channel``.""" + self._hand.scroll_into_view(self.channel_box(path, channel)) + self._hand.click(self.channel_box(path, channel)) + + def toggle_folder(self, path: Path) -> None: + """Clicks the marker beside a folder's name.""" + self._hand.scroll_into_view(self.twisty(path)) + self._hand.click(self.twisty(path)) + + def remove(self, path: Path) -> None: + """Clicks the row's remove button.""" + self._hand.scroll_into_view(self.remove_button(path)) + self._hand.click(self.remove_button(path)) + + +class MixQuestion: + """The question picking which recordings a mix takes, once more are gathered than a mix holds.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION)).shown + + def box(self, path: Path) -> str: + return self.tags.row(str(path), SUF_CHECKBOX) + + def name(self, path: Path) -> str: + return self.tags.row(str(path), ROW_TEXT) + + def is_ticked(self, path: Path) -> bool: + return bool(self._bridge.ask(lambda: dpg.get_value(self.box(path)))) + + def is_live(self, path: Path) -> bool: + """Whether the box answers a click, which a box past a full pick does not.""" + return self._bridge.ask(lambda: read_item(self.box(path))).enabled + + def is_highlighted(self, path: Path) -> bool: + return bool(self._bridge.ask(lambda: dpg.get_value(self.name(path)))) + + def count_line(self) -> str: + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT))) + + def can_add(self) -> bool: + return self._bridge.ask(lambda: read_item(_add_button())).enabled + + def tick(self, path: Path) -> None: + self._hand.scroll_into_view(self.box(path)) + self._hand.click(self.box(path)) + + def add(self) -> None: + self._hand.click(_add_button()) + + def cancel(self) -> None: + self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, SUF_BUTTON)) + + +class Converter: + """The Converter card: the list, the button naming the run, and the run's progress and questions.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.list = ConverterList(bridge, hand) + self.mix_question = MixQuestion(bridge, hand) + self.cancel_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_CANCEL) + self.end_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_LOAD) + self.overwrite_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET) + self.replace_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS) + self.notice = Notice(bridge, hand, compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_DIALOG_INFO)) + self.nothing_below_notice = Notice( + bridge, + hand, + compose_tag(TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, SUF_DIALOG_INFO), + ) + + def action(self) -> str: + """What the button under the list reads: the run it starts, or Cancel while one runs.""" + return self._bridge.ask(lambda: read_label(_action_button())) + + def press_action(self) -> None: + self._hand.scroll_into_view(_action_button()) + self._hand.click(_action_button()) + + def run_shown(self) -> bool: + """Whether the card shows a run under way: its status line and its progress.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP)).shown + + def status(self) -> str: + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STATUS))) + + def progress(self) -> float: + return float(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_PROGRESS))) + + def destination(self) -> str: + """The whole path of the folder the run writes into, as the hover over its shortened path shows it.""" + tooltip = compose_tag(TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, SUF_TOOLTIP) + return " ".join(self._bridge.ask(lambda: read_texts(tooltip))) + + def scan_shown(self) -> bool: + """Whether the window reading a folder stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_SCAN)).shown + + def scan_words(self) -> str: + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER))) + + def stop_scan(self) -> None: + self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON)) + + +def _add_button() -> str: + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON) + + +def _action_button() -> str: + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ACTION, SUF_BUTTON) + + +def _collect_row(item: Item, found: List[str]) -> bool: + alias = str(dpg.get_item_alias(item)) + if ROW_MARK in alias and alias.startswith(PRE_MAIN_CONVERTER_STEMS) and alias.endswith(f".{ROW_TEXT}"): + found.append(alias) + + return False diff --git a/tests/suite/screens/views/instructions.py b/tests/suite/screens/views/instructions.py index dd8439f26..76c006e4c 100644 --- a/tests/suite/screens/views/instructions.py +++ b/tests/suite/screens/views/instructions.py @@ -1,16 +1,26 @@ from typing import Optional +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, + TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_PANEL, TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS, TAG_INSTRUCTIONS_LIBRARY_TREE, ) from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, read_value +from tests.suite.screens.dearpygui.items import Item, read_label, read_value from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.notices import Notice from tests.suite.screens.views.prompts import Prompt +GENERATE_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, SUF_BUTTON) +CANCEL_GENERATION_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, SUF_BUTTON) + class Library: """The Instructions tab's library card: the libraries its folder holds, and the line saying where they stand.""" @@ -21,13 +31,27 @@ def __init__( hand: Hand, ) -> None: self._bridge = bridge + self._hand = hand self.tree = FileTree(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_TREE) self.rebuild_prompt = Prompt(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION) + self.regenerate_prompt = Prompt(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION) + self.notice = Notice(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_PANEL) def row(self, filename: str) -> Optional[Item]: """The row standing for the library stored under ``filename``, if one is drawn.""" return self.tree.library_row(lambda node: node.library_key.filename == filename) + def generate_label(self) -> str: + """What the button generating a library reads: Generate, or Regenerate where the library stands.""" + return self._bridge.ask(lambda: read_label(GENERATE_BUTTON)) + + def generate(self) -> None: + self._hand.scroll_into_view(GENERATE_BUTTON) + self._hand.click(GENERATE_BUTTON) + + def cancel_generation(self) -> None: + self._hand.click(CANCEL_GENERATION_BUTTON) + def status(self) -> str: """What the line under the tree says about the library in hand.""" return str(self._bridge.ask(lambda: read_value(TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS))) diff --git a/tests/suite/screens/views/main.py b/tests/suite/screens/views/main.py index 2afc7f9e0..f876d6a78 100644 --- a/tests/suite/screens/views/main.py +++ b/tests/suite/screens/views/main.py @@ -23,6 +23,8 @@ from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import read_item, read_texts, read_theme, read_value from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.converter import Converter +from tests.suite.screens.views.source import SourceSettings OUTPUT_LABEL_KEYS = { OutputKind.PER_RECORDING: "main.converter.label.mode_each", @@ -70,6 +72,8 @@ def __init__( self._hand = hand self._language = language self.advanced = Card(bridge, hand, TAG_MAIN_ADVANCED_PANEL) + self.converter = Converter(bridge, hand) + self.source = SourceSettings(bridge, hand) def card(self, tag: str) -> Card: return Card(self._bridge, self._hand, tag) diff --git a/tests/suite/screens/views/source.py b/tests/suite/screens/views/source.py new file mode 100644 index 000000000..49334f18f --- /dev/null +++ b/tests/suite/screens/views/source.py @@ -0,0 +1,40 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_CHANNELS, SUF_CHECKBOX +from sampletones_application.tags.main import PRE_MAIN_SOURCE_CHANNEL, TAG_MAIN_SOURCE_TEXT_SUBJECT +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_theme + + +class SourceSettings: + """The Source settings card: what the picked row converts with, channel by channel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def subject(self) -> str: + """What the card names as the row it speaks for.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT))) + + def channel_box(self, channel: ChannelName) -> str: + return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel, SUF_CHANNELS, SUF_CHECKBOX) + + def channel_ticked(self, channel: ChannelName) -> bool: + return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(channel)))) + + def channel_theme(self, channel: ChannelName) -> Optional[str]: + return self._bridge.ask(lambda: read_theme(self.channel_box(channel))) + + def tick(self, channel: ChannelName) -> None: + self._hand.scroll_into_view(self.channel_box(channel)) + self._hand.click(self.channel_box(channel)) diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py index f267b3004..834053e7a 100644 --- a/tests/suite/screens/world.py +++ b/tests/suite/screens/world.py @@ -10,6 +10,7 @@ from sampletones_core.configs import Config from sampletones_shared.paths.user import CONFIG_PATH from tests.suite.screens.environment import SCREEN_SIZE +from tests.suite.screens.seeds import MiniLibrary class HomeFile(Protocol): @@ -70,6 +71,24 @@ def screen_filling_state() -> ApplicationState: ) +def one_worker_config() -> Config: + """Reconstruction settings running one job at a time, which keeps a scenario's run on a worker of its own.""" + config = Config() + general = config.general.model_copy(update={"max_workers": 1}) + return config.model_copy(update={"general": general}) + + +def converting_world(files: Tuple[HomeFile, ...]) -> World: + """A home ready to convert: one-worker settings, a small library built for them, and ``files``.""" + config = one_worker_config() + return World( + state=screen_filling_state(), + application_config=None, + config=config, + files=(MiniLibrary(config), *files), + ) + + def lived_in_world() -> World: """The home of a user who ran the application once: a session left behind, and nothing else.""" return World( From 87d4518524cdad1d3cf804db898a093bd52c2271 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 14:49:00 +0200 Subject: [PATCH 09/19] Proved: every question that guards a document, on a live screen --- docs/development/application/screens.md | 17 +- docs/development/bugs-and-todos.md | 13 + tests/screens/application/test_old_files.py | 27 +- tests/screens/conftest.py | 2 + tests/screens/main/test_run.py | 19 +- tests/screens/prompts/__init__.py | 0 tests/screens/prompts/conftest.py | 9 + .../prompts/test_closing_over_a_modal.py | 268 ++++++++ tests/screens/prompts/test_every_door.py | 588 ++++++++++++++++++ tests/screens/prompts/test_keys.py | 293 +++++++++ tests/screens/prompts/test_load_chain.py | 270 ++++++++ .../screens/prompts/test_modal_over_modal.py | 491 +++++++++++++++ tests/screens/prompts/test_open_voice.py | 258 ++++++++ tests/screens/prompts/test_vanished.py | 131 ++++ tests/suite/screens/dearpygui/hand.py | 41 +- tests/suite/screens/dearpygui/items.py | 32 +- tests/suite/screens/holds.py | 64 +- tests/suite/screens/plugin.py | 14 +- tests/suite/screens/screen.py | 4 +- tests/suite/screens/seeds.py | 55 ++ tests/suite/screens/steps/main.py | 47 ++ tests/suite/screens/steps/reconstructions.py | 181 ++++++ tests/suite/screens/steps/sequencer.py | 28 + tests/suite/screens/views/display_settings.py | 24 + .../suite/screens/views/keyboard_settings.py | 55 ++ tests/suite/screens/views/menus.py | 45 +- tests/suite/screens/views/notices.py | 13 +- tests/suite/screens/views/project.py | 4 + tests/suite/screens/views/prompts.py | 31 +- tests/suite/screens/views/reconstructions.py | 99 ++- tests/suite/screens/views/sequencer.py | 40 +- tests/suite/screens/world.py | 43 +- 32 files changed, 3112 insertions(+), 94 deletions(-) create mode 100644 tests/screens/prompts/__init__.py create mode 100644 tests/screens/prompts/conftest.py create mode 100644 tests/screens/prompts/test_closing_over_a_modal.py create mode 100644 tests/screens/prompts/test_every_door.py create mode 100644 tests/screens/prompts/test_keys.py create mode 100644 tests/screens/prompts/test_load_chain.py create mode 100644 tests/screens/prompts/test_modal_over_modal.py create mode 100644 tests/screens/prompts/test_open_voice.py create mode 100644 tests/screens/prompts/test_vanished.py create mode 100644 tests/suite/screens/steps/reconstructions.py create mode 100644 tests/suite/screens/steps/sequencer.py create mode 100644 tests/suite/screens/views/keyboard_settings.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index d44fe9eee..a322def14 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -37,7 +37,8 @@ draws on an X server of its own, so its scenarios take only its own input. The `world` fixture says what the home holds before the first frame: the session a previous run left, the settings, and the recordings, documents and libraries the scenario works on. Each file is written by the model and the manager the application reads it with, so a seeded home is one a real run could have left. A -scenario class overrides `world` to seed its own. The [compatibility corpus](../../../tests/data/compatibility/README.md) +scenario class overrides `world` to seed its own, and a folder of scenarios working on the same documents +overrides it once in its `conftest.py`. The [compatibility corpus](../../../tests/data/compatibility/README.md) supplies the documents a release wrote, whole or damaged: restated at a version no step reaches, cut short, or replaced by bytes of another kind. @@ -124,6 +125,10 @@ Keys are pressed on the real keyboard, with modifiers held a frame before the ke action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key, so a key held across a slow frame arrives as one press. +A person lets go of a click some frames after pressing it, and DearPyGui answers a double-click as its +second press goes down. A scenario holding that press for as long as a person does sees what the answer +brings under a button still down. + A gesture confirms that it arrived. Before a button goes down, the control must report the pointer resting on it: DearPyGui calls a control visible while the region it scrolls in clips it, and a press there lands on something else. While a button or a named key is held, the application must read it as down. A press @@ -155,10 +160,13 @@ A gesture made while work runs is only proven if the work is provably still runn lands. A hold keeps it there: a stand-in decides **when** the work lands and never **what** it computes. A held conversion walks the stages a real one reports and stops halfway through matching, and a held folder scan meets the folder's entries and then one entry after another that is no recording. Each goes -on reporting while it is held, so Stop and Cancel are heard where the real work hears them. A hold lasts +on reporting while it is held, so Stop and Cancel are heard where the real work hears them. A held rebuild +of an edited channel waits on its worker before it computes, so the edit is drawn while it is on its way, +and the real rebuild runs once it is released. A scenario standing for a rebuild that breaks lets the held +rebuilds go as that failure, which reaches the application the way a rebuild that raised does. A hold lasts until the scenario releases it, and every hold is released before the scenario leaves, because leaving -waits for the work in flight. A scenario asks for a hold through its fixture, `conversion_hold` or -`scan_hold`. +waits for the work in flight. A scenario asks for a hold through its fixture: `conversion_hold`, +`scan_hold` or `regeneration_hold`. ## What survives a restart @@ -179,6 +187,7 @@ scenario: - **In flight.** What if the user acts while its work is still running? - **Unsaved work.** What does every path that replaces or closes a document ask? - **Twice.** What does the same gesture do twice, and a double click against a single one? +- **Held press.** What does a press still down when the screen changes do to what comes under the pointer? - **Key scope.** Which scope claims its keys: a field, a modal, a panel, a collapsed card or a rebound key? - **Palette.** Does a live palette swap repaint it? - **Restart.** What of it survives a restart? diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index d3e53432e..129daa8b1 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -182,3 +182,16 @@ currently out of line. An entry leaves when the code meets the contract again. * A library folder pointed away from and back lists its library unloaded: the library loaded before reads as one that exists, where it should come back loaded. `tests/screens/main/test_library.py` reproduces it as a known failure. +* A reconstruction whose file the browser removed reads as a sample of the project: the NES frequency + field locks with the hint that the project sets its rate, though the document belongs to no + project. `tests/screens/prompts/test_vanished.py` reproduces it as a known failure. +* A voice double-clicked with the second press still held opens edited. The double-click brings the + Reconstructions tab forward while the button is down, and the envelope graph that comes under the + pointer draws a bar for a press that began on the Voices card. `tests/screens/prompts/test_open_voice.py` + reproduces it as a known failure. +* A Keyboard settings row reads as listening once Cancel answers the reassign question, while no key + reaches it: the keys pressed next are taken by nothing, and Escape closes the dialog. + `tests/screens/prompts/test_modal_over_modal.py` reproduces it as a known failure. +* Two closes before the first is answered ask twice: each close puts its question in line, so Cancel + on the first brings the second, and so does a close made twice while an edit is on its way. + `tests/screens/prompts/test_closing_over_a_modal.py` reproduces it as a known failure. diff --git a/tests/screens/application/test_old_files.py b/tests/screens/application/test_old_files.py index 6bb4b59c7..145620518 100644 --- a/tests/screens/application/test_old_files.py +++ b/tests/screens/application/test_old_files.py @@ -29,11 +29,18 @@ ) from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document, stored_version -from tests.suite.screens.application import NOTHING_TO_OPEN, Startup +from tests.suite.screens.application import Startup from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.dearpygui.items import Item from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Damage, Recording, WrittenBytes, archived_document, damaged_document +from tests.suite.screens.seeds import ( + Damage, + Recording, + WrittenBytes, + archived_document, + damaged_document, + stored_recording, +) from tests.suite.screens.views.notices import Notice from tests.suite.screens.world import HomeFile, World, screen_filling_state @@ -46,7 +53,6 @@ OLDER_PROJECT_VERSION: Final[str] = "0.9" RECORDING_SECONDS: Final[float] = 0.5 RECORDING_FREQUENCY: Final[float] = 220.0 -AUDIO_PATH_FIELD: Final[str] = "audio_filepath" SAMPLES_FIELD: Final[str] = "samples" NAME_FIELD: Final[str] = "name" CONFIG_FIELD: Final[str] = "config" @@ -62,19 +68,6 @@ def future_version(current: str) -> str: return f"{int(major) + 1}.0" -def stored_recording() -> Recording: - """The recording the archived reconstruction names, laid where its relative path leads from the home. - - The application runs in the home, so a relative path the document names resolves there. - """ - document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) - return Recording( - destination=Path.cwd() / document[AUDIO_PATH_FIELD], - seconds=RECORDING_SECONDS, - frequency=RECORDING_FREQUENCY, - ) - - def stored_voice_names() -> List[str]: """The voice names the archived project stores, read off its document without the application's model.""" document = stored_document(archived(ObjectKind.PROJECT, ARCHIVED_VERSIONS[ObjectKind.PROJECT])) @@ -447,7 +440,7 @@ def open_project(screen: Screen, path: Path) -> None: def refused_project(screen: Screen, path: Path) -> str: - """Reads the notice refusing ``path``, dismisses it, checks the project open before stands, and returns its words.""" + """Reads the notice refusing ``path``, dismisses it, checks the project open before stands, returning the words.""" notice = shown_notice(screen) words = notice.words() screen.claim_error(path.name) diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py index 5d075630c..cbee6a1cb 100644 --- a/tests/screens/conftest.py +++ b/tests/screens/conftest.py @@ -5,6 +5,7 @@ pytest_pyfunc_call, pytest_runtest_protocol, pytest_unconfigure, + regeneration_hold, scan_hold, screen, screen_application, @@ -23,6 +24,7 @@ "pytest_pyfunc_call", "pytest_runtest_protocol", "pytest_unconfigure", + "regeneration_hold", "scan_hold", "screen", "screen_application", diff --git a/tests/screens/main/test_run.py b/tests/screens/main/test_run.py index 539e332c7..0b58df21f 100644 --- a/tests/screens/main/test_run.py +++ b/tests/screens/main/test_run.py @@ -1,7 +1,6 @@ import multiprocessing import operator import shutil -from functools import partial from pathlib import Path from typing import Dict, Final, FrozenSet, List, Tuple @@ -16,7 +15,7 @@ from tests.suite.screens.holds import ConversionHold from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording -from tests.suite.screens.steps.main import explorer_row, gather, home_path +from tests.suite.screens.steps.main import explorer_row, gather, give_each_its_own_channel, home_path from tests.suite.screens.world import HomeFile, World, converting_world SECONDS: Final[float] = 0.3 @@ -173,20 +172,6 @@ def no_input_after_it(screen: Screen) -> None: screen.scenario(no_input_before_the_run, input_while_it_reads, no_input_after_it).run() -def give_each_its_own_channel(screen: Screen, channels: Dict[Path, ChannelName]) -> None: - """Leaves each gathered recording the one channel ``channels`` names for it.""" - listing = screen.main.converter.list - for path, keep in channels.items(): - for channel in ChannelName: - if listing.channel_ticked(path, channel) != (channel == keep): - listing.tick(path, channel) - screen.expect( - partial(listing.channel_ticked, path, channel), - (channel == keep).__eq__, - description=f"{path.name} {channel}", - ) - - def written_as(path: Path) -> Path: """The one reconstruction a run wrote for the recording at ``path``, wherever below the folder it went.""" found = [file for file in written(RECONSTRUCTIONS_DIRECTORY) if file.stem == path.stem] @@ -312,7 +297,7 @@ def convert_anyway_replaces_those_alone(screen: Screen) -> None: class TestReconstructingAFileWhileAListStands: - """Reconstruct file with a list gathered asks first: Keep the list converts nothing, Replace it converts that file alone.""" + """Reconstruct file with a list gathered asks first: Keep the list converts none, Replace it converts the file.""" def test_keep_the_list_then_replace_it(self, screen: Screen) -> None: converter = screen.main.converter diff --git a/tests/screens/prompts/__init__.py b/tests/screens/prompts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/conftest.py b/tests/screens/prompts/conftest.py new file mode 100644 index 000000000..c26dae79c --- /dev/null +++ b/tests/screens/prompts/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.world import World, documents_world + + +@pytest.fixture +def world() -> World: + """The home every prompt scenario starts in: documents to put away and replace, and recordings to convert.""" + return documents_world() diff --git a/tests/screens/prompts/test_closing_over_a_modal.py b/tests/screens/prompts/test_closing_over_a_modal.py new file mode 100644 index 000000000..6fc7b260c --- /dev/null +++ b/tests/screens/prompts/test_closing_over_a_modal.py @@ -0,0 +1,268 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.holds import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title +from tests.suite.screens.steps.reconstructions import ( + expect_open, + first_raised, + marked, + raise_the_first_level_while_held, + titled, +) +from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION + +SETTLING_FRAMES: Final[int] = 30 +PROJECT: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" +NEW_TITLE: Final[str] = "After closing" +EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" + + +def change_a_saved_project(screen: Screen) -> None: + """Saves a new project titled, then titles it anew, which leaves it unsaved.""" + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT) + + retitle_project(screen, NEW_TITLE) + + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + +def leave_letting_the_project_go(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + +class TestClosingTheWindowTwiceAtOnce: + """Two closes before the first is answered ask about the unsaved project once.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: two closes before the first is answered ask twice", + ) + def test_one_question_and_cancel_keeps_the_application(self, screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + def close_twice(screen: Screen) -> None: + screen.close_window() + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.is_running() + + def close_once_more(screen: Screen) -> None: + screen.close_window() + + leave_letting_the_project_go(screen) + + screen.scenario(change_a_saved_project, close_twice, cancel_leaves_no_question, close_once_more).run() + + +class TestClosingTheWindowOverTheReassignQuestion: + """Closing the window while Keyboard settings asks to reassign keys asks about the project after the dialog.""" + + def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + prompt = screen.project.unsaved_prompt + + def ask_to_reassign(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert reassign.is_shown() + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def answer_the_dialog_and_its_question(screen: Screen) -> None: + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + + settings.cancel() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert len(screen.shown_windows()) == 1 + + screen.scenario( + change_a_saved_project, + ask_to_reassign, + close_the_window, + answer_the_dialog_and_its_question, + leave_letting_the_project_go, + ).run() + + +class TestClosingTheWindowOverTheOverwriteQuestion: + """Closing the window while the Converter asks to replace a run's files asks about the project after it.""" + + def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + converter = screen.main.converter + overwrite = converter.overwrite_prompt + prompt = screen.project.unsaved_prompt + + def convert_and_ask_to_convert_again(screen: Screen) -> None: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the run's end answered") + + converter.press_action() + + screen.expect(overwrite.is_shown, bool, description="the question about replacing") + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert overwrite.is_shown() + assert not prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + overwrite.cancel() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert not converter.run_shown() + assert len(screen.shown_windows()) == 1 + + screen.scenario( + change_a_saved_project, + convert_and_ask_to_convert_again, + close_the_window, + cancel_hands_the_screen_over, + leave_letting_the_project_go, + ).run() + + +class TestClosingTheWindowWhileAnEditIsOnItsWay: + """A close asked for while an edit is on its way asks about the edited reconstruction once the edit lands.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: two closes before the first is answered ask twice", + ) + def test_two_closes_ask_once(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + + def edit_and_close_twice_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + screen.close_window() + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_edit_lands_and_one_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + screen.scenario( + edit_and_close_twice_while_the_rebuild_is_held, + the_edit_lands_and_one_question_comes, + cancel_leaves_no_question, + ).run() + + def test_an_edit_made_after_the_close_is_drawn_away( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_and_close_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def edit_again_after_the_close(screen: Screen) -> None: + reconstructions.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + typed[0].__eq__, + description="the field drawing the edit that landed", + ) + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving again") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_and_close_while_the_rebuild_is_held, + edit_again_after_the_close, + the_first_edit_lands_and_the_second_is_drawn_away, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/test_every_door.py b/tests/screens/prompts/test_every_door.py new file mode 100644 index 000000000..df5986cf9 --- /dev/null +++ b/tests/screens/prompts/test_every_door.py @@ -0,0 +1,588 @@ +import operator +from dataclasses import dataclass +from enum import StrEnum +from functools import partial +from typing import Callable, Final, List, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.reconstructions import ( + converted, + edit_envelope, + expect_open, + load_from_the_browser, + marked, + raise_the_first_level, + remove_from_the_browser, + titled, + voice_title, +) +from tests.suite.screens.steps.sequencer import double_click_voice, open_voice +from tests.suite.screens.world import ( + BASS, + LEAD, + OPEN_RECONSTRUCTION, + OTHER_RECONSTRUCTION, + SONG, + SONG_INSTRUMENT, + SONG_SAMPLE, +) + +SETTLING_FRAMES: Final[int] = 10 +SAMPLE_ORDINAL: Final[int] = 0 +FADING: Final[str] = "12 8 4" +LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" +LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" +CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" +CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" +EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" +EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" +EXIT_TITLE: Final[str] = "global.dialog.title.exit_confirmation" +EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" +EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" +SAVE: Final[str] = "global.dialog.label.save" +DISCARD: Final[str] = "global.dialog.label.discard" +CLOSE: Final[str] = "global.dialog.label.close" +EXIT: Final[str] = "global.dialog.label.exit" +CANCEL: Final[str] = "global.dialog.label.cancel" + + +class Door(StrEnum): + """A gesture that puts the open document away, to replace it or to leave. + + A voice opens from a double-click on its row and from Edit on its row's menu. + """ + + BROWSER = "browser" + MENU_OPEN = "menu_open" + CLOSE = "close" + VOICE = "voice" + VOICE_MENU = "voice_menu" + CONVERSION_LOAD = "conversion_load" + EXIT = "exit" + WINDOW_CLOSE = "window_close" + + +ASKING_DOORS: Final[Tuple[Door, ...]] = tuple(Door) + + +@dataclass(frozen=True) +class Question: + """A question as the reader meets it: its title, its words and the answers it offers.""" + + title: str + words: str + answers: Tuple[str, ...] + + +@dataclass(frozen=True) +class Standing: + """What the screen shows of the open document, which a question taken back leaves as it was.""" + + title: str + file_line: str + volume: str + windows: Tuple[str, ...] + dialogs: int + + +def question_at(screen: Screen, door: Door) -> Question: + """The question ``door`` asks of a standalone reconstruction with unsaved changes, as the language file words it.""" + match door: + case Door.BROWSER | Door.MENU_OPEN | Door.CONVERSION_LOAD: + return Question( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.CLOSE: + return Question( + screen.words(CLOSE_TITLE), + screen.words(CLOSE_MESSAGE), + (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)), + ) + case Door.VOICE | Door.VOICE_MENU: + return Question( + screen.words(EDIT_TITLE), + screen.words(EDIT_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.EXIT | Door.WINDOW_CLOSE: + return Question( + screen.words(EXIT_TITLE), + screen.words(EXIT_RECONSTRUCTION_MESSAGE), + (screen.words(SAVE), screen.words(EXIT), screen.words(CANCEL)), + ) + + +def knock(screen: Screen, door: Door, *, voice: str) -> None: + """Makes the gesture ``door`` stands for; a door that loads loads Other.stn, the run's bass, or ``voice``.""" + match door: + case Door.BROWSER: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + case Door.MENU_OPEN: + screen.reconstructions.open_from_menu() + case Door.CLOSE: + screen.reconstructions.close_from_menu() + case Door.VOICE: + double_click_voice(screen, voice) + case Door.VOICE_MENU: + open_voice(screen, voice) + case Door.CONVERSION_LOAD: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + screen.main.converter.end_prompt.confirm() + screen.expect(screen.main.converter.end_prompt.is_shown, operator.not_, description="the end answered") + case Door.EXIT: + screen.press_shortcut(ShortcutId.EXIT) + case Door.WINDOW_CLOSE: + screen.close_window() + + +def standing(screen: Screen) -> Standing: + reconstructions = screen.reconstructions + return Standing( + title=screen.title(), + file_line=reconstructions.file_line(), + volume=reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + windows=tuple(window.alias for window in screen.shown_windows()), + dialogs=len(screen.dialog_requests()), + ) + + +def asked(screen: Screen, door: Door) -> Question: + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description=f"the question {door} asks") + return Question(prompt.title(), prompt.words(), prompt.answers()) + + +def asks_and_cancel_keeps_it(door: Door, *, voice: str) -> Callable[[Screen], None]: + """The step knocking at ``door``, which asks its question, then taking it back, which leaves all standing.""" + + def step(screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + before = standing(screen) + + knock(screen, door, voice=voice) + + assert asked(screen, door) == question_at(screen, door) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert standing(screen) == before + + step.__name__ = f"{door.value}_asks_and_cancel_keeps_it" + return step + + +def leave_letting_it_go(screen: Screen) -> None: + """Exits, answering Exit to the question about the reconstruction.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the reconstruction") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, answering Exit to the question about the project.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def nothing_asked(screen: Screen) -> None: + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + +def expect_title(screen: Screen, title: str) -> None: + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") + + +class TestAnEditedReconstructionAtEveryDoor: + """Every door asks about an edited reconstruction in its own words, and Cancel leaves everything standing.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + def edit_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + screen.scenario( + edit_it, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() + + +class TestAReconstructionWhoseFileWasRemovedAtEveryDoor: + """Every door asks about a reconstruction whose file was removed, as it asks about an edited one.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + expect_title(screen, titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True))) + + screen.scenario( + remove_its_file, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() + + +class TestAnEditedSampleAtEveryDoor: + """An edited sample of the project is put away without a question, its edits kept in the project. + + The exit asks about the project alone. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_the_exit_asks_about_the_project(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + edit: List[str] = [] + sample_open = partial(voice_title, screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True) + + def open_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, sample_open()) + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] + + def edit_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + + edit.append(raise_the_first_level(screen, ChannelName.PULSE1, title=sample_open())) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def another_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_INSTRUMENT) + + screen.expect(reconstructions.offers_audition, bool, description="the instrument open") + assert screen.title() == titled(screen, marked(SONG.stem, unsaved=True)) + nothing_asked(screen) + open_the_sample(screen) + + def close_closes_at_once(screen: Screen) -> None: + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True))) + assert reconstructions.file_line() == "" + nothing_asked(screen) + open_the_sample(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_sample(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert not reconstructions.unsaved_prompt.is_shown() + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.title() == sample_open() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_sample, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + another_voice_opens_at_once, + close_closes_at_once, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() + + +class TestAnInstrumentAtEveryDoor: + """An instrument is put away without a question, Close has nothing to close, and the exit asks about the project.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_close_stands_aside(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + edited = titled(screen, marked(SONG.stem, unsaved=True)) + + def open_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.offers_audition, bool, description="the instrument open") + screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="its edit drawn", + ) + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.offers_audition, bool, description="the instrument open") + + edit_envelope(screen, channel=ChannelName.PULSE1, feature=FeatureKey.VOLUME, sequence=FADING, title=edited) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + assert not reconstructions.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_instrument(screen) + + def a_sample_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True)) + assert not reconstructions.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert reconstructions.offers_audition() + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING + assert screen.title() == edited + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_instrument(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert reconstructions.offers_audition() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_instrument, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_sample_opens_at_once, + close_has_nothing_to_close, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() + + +class TestASavedReconstructionAtEveryDoor: + """A reconstruction with nothing unsaved is put away at every door without a question, and leaving leaves.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_no_door_asks(self, screen: Screen) -> None: + def the_browser_loads_at_once(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OPEN_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OPEN_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + + def close_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem)) + nothing_asked(screen) + + def the_window_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() + + screen.scenario( + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + the_conversion_loads_at_once, + a_voice_opens_at_once, + close_closes_at_once, + the_window_closes_at_once, + ).run() + + def test_exit_leaves_at_once(self, screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.EXIT, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() + + +class TestNothingOpenAtEveryDoor: + """With nothing open, every door that loads loads at once, and Close has nothing to close.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_each_door_loads_at_once(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + empty = titled(screen, SONG.stem) + + def close_it_again(screen: Screen) -> None: + reconstructions.close_from_menu() + expect_title(screen, empty) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert screen.title() == empty + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + close_it_again(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + screen.scenario( + close_has_nothing_to_close, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_voice_opens_at_once, + the_conversion_loads_at_once, + ).run() + + def test_the_window_closes_at_once(self, screen: Screen) -> None: + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the song open") + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/test_keys.py b/tests/screens/prompts/test_keys.py new file mode 100644 index 000000000..827d62275 --- /dev/null +++ b/tests/screens/prompts/test_keys.py @@ -0,0 +1,293 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.project import retitle_project, saved_project_title +from tests.suite.screens.steps.reconstructions import ( + converted, + expect_open, + first_level_raised, + leading, + marked, + raise_the_first_level, + remove_from_the_browser, + stored_levels, + titled, +) +from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +RETITLED: Final[str] = "Retitled from the keyboard" +KEPT: Final[str] = "Kept.stn" +SETTLING_FRAMES: Final[int] = 10 +EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" +EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" +CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" + + +def tab(screen: Screen, times: int) -> None: + for _ in range(times): + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + + +def enter(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + +def escape(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + +def edited_title(screen: Screen) -> str: + return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + +class TestTheExitChainFromTheKeyboard: + """Tab, Enter and Escape answer the question about the reconstruction that the project's answer opens. + + Each question starts on Cancel, so one Tab reaches Save and two reach Exit. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_second_keeps_everything_and_the_keys_then_save_and_leave(self, screen: Screen) -> None: + project_prompt = screen.project.unsaved_prompt + reconstruction_prompt = screen.reconstructions.unsaved_prompt + stored: List[bytes] = [] + + def open_the_project_and_change_both(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + screen.answer_next_dialog(DialogKind.OPEN, SONG) + screen.project.open() + screen.expect( + screen.sequencer.voices.names, [SONG_SAMPLE, SONG_INSTRUMENT].__eq__, description="the song's voices" + ) + retitle_project(screen, RETITLED) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + def exit_asks_about_the_project(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project_prompt.is_shown, bool, description="the question about the project") + assert project_prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + + def exit_on_the_project_asks_about_the_reconstruction(screen: Screen) -> None: + tab(screen, 2) + enter(screen) + + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + assert reconstruction_prompt.words() == screen.words(EXIT_RECONSTRUCTION_MESSAGE) + assert not project_prompt.is_shown() + + def escape_keeps_the_application_and_both_changes(screen: Screen) -> None: + escape(screen) + + screen.expect(reconstruction_prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert screen.shown_windows() == () + assert screen.title() == titled( + screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True) + ) + assert saved_project_title(SONG) != RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_keys_save_the_project_and_leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(project_prompt.is_shown, bool, description="the question about the project again") + tab(screen, 1) + enter(screen) + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + + tab(screen, 2) + enter(screen) + + assert screen.wait_for_exit() + assert saved_project_title(SONG) == RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + open_the_project_and_change_both, + exit_asks_about_the_project, + exit_on_the_project_asks_about_the_reconstruction, + escape_keeps_the_application_and_both_changes, + the_keys_save_the_project_and_leave, + ).run() + + +class TestTheLoadChainFromTheKeyboard: + """The keys answer Load at a run's end, and the question about unsaved changes that Load opens.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_keeps_the_edits_and_the_keys_then_save_and_load(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = screen.reconstructions.unsaved_prompt + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + + def load_from_the_keyboard(screen: Screen, recording: str) -> None: + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert not converter.end_prompt.is_shown() + + def escape_keeps_the_edited_one(screen: Screen) -> None: + load_from_the_keyboard(screen, BASS) + + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen) + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def save_and_load_from_the_keyboard(screen: Screen) -> None: + load_from_the_keyboard(screen, LEAD) + + tab(screen, 1) + enter(screen) + + expect_open(screen, converted(home_path(LEAD))) + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + escape_keeps_the_edited_one, + save_and_load_from_the_keyboard, + ).run() + + +class TestASaveThatAsksWhereFromTheKeyboard: + """The question a dismissed save dialog brings back answers to the keys like the first one.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_returned_question_then_the_keys_save_where_asked(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def take_its_file_away(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect(screen.title, edited_title(screen).__eq__, description="the reconstruction left unsaved") + + def save_asks_where_and_a_dismissed_dialog_asks_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.words() == screen.words(CLOSE_MESSAGE) + screen.answer_next_dialog(DialogKind.SAVE, None) + + tab(screen, 1) + enter(screen) + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + assert len(prompt.shown_windows()) == 1 + + def escape_keeps_it_open(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.title() == edited_title(screen) + assert screen.shown_windows() == () + + def the_keys_save_it_where_asked_and_close_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + tab(screen, 1) + enter(screen) + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + assert not prompt.is_shown() + + screen.scenario( + take_its_file_away, + save_asks_where_and_a_dismissed_dialog_asks_again, + escape_keeps_it_open, + the_keys_save_it_where_asked_and_close_it, + ).run() + + +class TestTheDisplayQuestionFromTheKeyboard: + """The question Escape asks of changed Display settings answers to the keys, as does the dialog after it.""" + + def test_enter_keeps_editing_and_tab_enter_discards(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + def escape_asks(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, bool, description="the discard question") + + def enter_keeps_editing(screen: Screen) -> None: + enter(screen) + + screen.expect(settings.is_shown, bool, description="the dialog back") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def escape_then_tab_and_enter_discard(screen: Screen) -> None: + escape(screen) + screen.expect(prompt.is_shown, bool, description="the discard question again") + + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not settings.is_shown() + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog opened again") + assert settings.vsync() == original[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed") + + screen.scenario(change_vertical_sync, escape_asks, enter_keeps_editing, escape_then_tab_and_enter_discard).run() diff --git a/tests/screens/prompts/test_load_chain.py b/tests/screens/prompts/test_load_chain.py new file mode 100644 index 000000000..14eab1af1 --- /dev/null +++ b/tests/screens/prompts/test_load_chain.py @@ -0,0 +1,270 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import StoredReconstruction +from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, convert_alone, home_path +from tests.suite.screens.steps.reconstructions import ( + converted, + expect_open, + first_level_raised, + leading, + load_from_the_browser, + marked, + raise_the_first_level, + stored_levels, + titled, +) +from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION + +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" +LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" +REPLACED_MESSAGE: Final[str] = "global.dialog.message.load_replaced_reconstruction" +SAVE: Final[str] = "global.dialog.label.save" +DISCARD: Final[str] = "global.dialog.label.discard" +CANCEL: Final[str] = "global.dialog.label.cancel" + + +def convert_and_ask_to_load(screen: Screen, recording: str) -> None: + """Converts the home's ``recording`` alone on Pulse 1, leaving a volume to edit, and answers Load at the end.""" + converter = screen.main.converter + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + + converter.end_prompt.confirm() + + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end answered") + + +def load_question(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + """The question about unsaved changes a load asks: its title, its words and its answers.""" + return ( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + + +def asked(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + return prompt.title(), prompt.words(), prompt.answers() + + +def edited_title(screen: Screen) -> str: + return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + +class TestLoadingAConversionOverAnEditedReconstruction: + """Load at a run's end, with an edited reconstruction open, asks about its changes first. + + Save writes them and loads the conversion, Discard loads it and leaves the file as it was, and + Cancel keeps the edited reconstruction open. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_cancel_keeps_the_edits_and_save_writes_them_before_loading(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + stored: List[bytes] = [] + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + typed.append(raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen))) + + def load_asks_about_the_changes(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + + assert asked(screen) == load_question(screen) + + def cancel_keeps_the_edited_one_open(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen) + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def save_writes_the_changes_and_loads_the_next(screen: Screen) -> None: + convert_and_ask_to_load(screen, LEAD) + assert asked(screen) == load_question(screen) + + prompt.save() + + expect_open(screen, converted(home_path(LEAD))) + assert screen.title() == titled(screen, converted(home_path(LEAD)).name) + assert not prompt.is_shown() + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + load_asks_about_the_changes, + cancel_keeps_the_edited_one_open, + save_writes_the_changes_and_loads_the_next, + ).run() + + def test_discard_loads_the_conversion_and_leaves_the_file(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + stored: List[bytes] = [] + standing: Dict[ChannelName, List[int]] = {} + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + standing.update({channel: stored_levels(OPEN_RECONSTRUCTION, channel) for channel in PULSES}) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + + def discard_loads_the_conversion(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + assert asked(screen) == load_question(screen) + + prompt.confirm() + + expect_open(screen, converted(home_path(BASS))) + assert screen.title() == titled(screen, converted(home_path(BASS)).name) + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_file_takes_a_save_made_later(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level(screen, ChannelName.PULSE2, title=edited_title(screen)) + + reconstructions.save_from_menu() + + screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") + second = standing[ChannelName.PULSE2] + assert leading(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE2), second) == first_level_raised(second) + assert stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) == standing[ChannelName.PULSE1] + + screen.scenario( + edit_the_open_reconstruction, + discard_loads_the_conversion, + the_file_takes_a_save_made_later, + ).run() + + +class TestAConversionWrittenOverTheOpenFile: + """A run writing over the edited reconstruction open offers Discard and Cancel alone, Cancel keeping the edits.""" + + def test_only_discard_and_cancel_and_each_does_what_it_says(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + converter = screen.main.converter + prompt = reconstructions.replaced_prompt + drawn: Dict[str, str] = {} + + def convert_and_load(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + + expect_open(screen, converted(home_path(BASS))) + drawn["converted"] = screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + bool, + description="Pulse 1's volume drawn", + ) + + def edit_it(screen: Screen) -> None: + drawn["edit"] = raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)), + ) + + def convert_over_it(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + converter.press_action() + screen.expect(converter.overwrite_prompt.is_shown, bool, description="the question about replacing") + converter.overwrite_prompt.confirm() + screen.bridge.expect( + converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS + ) + + converter.end_prompt.confirm() + + screen.expect(prompt.is_shown, bool, description="the question about the file written over") + assert prompt.title() == screen.words(LOAD_TITLE) + assert prompt.words() == screen.words(REPLACED_MESSAGE) + assert prompt.answers() == (screen.words(DISCARD), screen.words(CANCEL)) + assert not reconstructions.unsaved_prompt.is_shown() + + def cancel_keeps_the_edits(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)) + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["edit"] + + def discard_loads_what_the_run_wrote(screen: Screen) -> None: + convert_over_it(screen) + + prompt.confirm() + + screen.expect( + screen.title, + titled(screen, converted(home_path(BASS)).name).__eq__, + description="the conversion loaded", + ) + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["converted"] + + screen.scenario( + convert_and_load, + edit_it, + convert_over_it, + cancel_keeps_the_edits, + discard_loads_what_the_run_wrote, + ).run() + + +class TestReloadingACleanReconstruction: + """A reconstruction with nothing unsaved loads its own file again without a question, as the file now stands.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_file_changed_on_disk_loads_as_it_stands(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + standing: List[str] = [] + + def change_the_file_behind_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.append(reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + reconstructions.save_from_menu() + screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") + + StoredReconstruction(OPEN_RECONSTRUCTION).write() + + def reloading_asks_nothing(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + standing[0].__eq__, + description="the file as it now stands", + ) + assert not reconstructions.unsaved_prompt.is_shown() + assert screen.title() == titled(screen, OPEN_RECONSTRUCTION.name) + + screen.scenario(change_the_file_behind_it, reloading_asks_nothing).run() diff --git a/tests/screens/prompts/test_modal_over_modal.py b/tests/screens/prompts/test_modal_over_modal.py new file mode 100644 index 000000000..8e2cfd39e --- /dev/null +++ b/tests/screens/prompts/test_modal_over_modal.py @@ -0,0 +1,491 @@ +import operator +import stat +from typing import Dict, Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.holds import ConversionHold, RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, gather, home_path +from tests.suite.screens.steps.reconstructions import ( + expect_open, + load_from_the_browser, + marked, + raise_the_first_level_while_held, + remove_from_the_browser, + titled, +) +from tests.suite.screens.world import BASS, OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION + +SETTLING_FRAMES: Final[int] = 30 +KEPT: Final[str] = "Kept.stn" +LOCKED_FOLDER: Final[str] = "Locked" +READ_AND_ENTER: Final[int] = stat.S_IRUSR | stat.S_IXUSR +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" +CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" +SAVE_FAILED: Final[str] = "global.dialog.message.reconstruction_save_failed" +REBUILD_FAILURE: Final[str] = "The rebuild broke on its worker" +LISTENING: Final[str] = "settings.keybindings.message.capturing" + + +class RebuildBrokeError(RuntimeError): + """The failure a held rebuild is let go as, standing for a rebuild that raised.""" + + +def start_a_held_run(screen: Screen) -> None: + """Converts the home's bass, which the hold keeps running halfway.""" + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + + +def let_the_run_end(screen: Screen, hold: ConversionHold) -> None: + """Lets the held run go, and waits for it to be over while its question waits its turn.""" + converter = screen.main.converter + hold.release() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.frames(SETTLING_FRAMES) + + +def the_end_comes_and_closes(screen: Screen) -> None: + """The run's question comes once the screen is free, and Close puts it away.""" + end = screen.main.converter.end_prompt + screen.expect(end.is_shown, bool, description="the run's question") + assert len(screen.shown_windows()) == 1 + + end.cancel() + + screen.expect(end.is_shown, operator.not_, description="the run's question answered") + assert screen.shown_windows() == () + + +class TestARunEndingBehindDisplaySettings: + """A run that ends while Display settings stands asks its question once the dialog is answered.""" + + def test_the_question_waits_for_an_unchanged_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + settings = screen.display_settings + + def open_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + + settings.open() + + screen.expect(settings.is_shown, bool, description="Display settings") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + open_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_hands_the_screen_over, + ).run() + + def test_the_discard_question_comes_before_the_runs(self, screen: Screen, conversion_hold: ConversionHold) -> None: + settings = screen.display_settings + discard = settings.discard_prompt + + def change_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original = settings.vsync() + + settings.toggle_vsync() + + screen.expect(settings.vsync, original.__ne__, description="the box flipped") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_asks_about_the_change_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(discard.is_shown, bool, description="the discard question") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def discard_hands_the_screen_over(screen: Screen) -> None: + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + assert not settings.is_shown() + the_end_comes_and_closes(screen) + + screen.scenario( + change_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_asks_about_the_change_first, + discard_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheWindowModeCountdown: + """A run that ends while the window-mode countdown stands waits for the countdown and the dialog behind it.""" + + def test_the_question_waits_for_keep_and_the_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + settings = screen.display_settings + discard = settings.discard_prompt + original: List[bool] = [] + + def change_the_window_mode_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original.append(settings.borderless()) + + settings.toggle_borderless() + + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def keep_brings_the_dialog_back_first(screen: Screen) -> None: + settings.keep() + + screen.expect(settings.is_shown, bool, description="Display settings back") + screen.frames(SETTLING_FRAMES) + assert not settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + assert settings.borderless() != original[0] + + def discarding_the_change_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + screen.expect(discard.is_shown, bool, description="the discard question") + + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + the_end_comes_and_closes(screen) + + screen.scenario( + change_the_window_mode_over_a_held_run, + the_run_ends_and_its_question_waits, + keep_brings_the_dialog_back_first, + discarding_the_change_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheReassignQuestion: + """A run that ends while Keyboard settings asks to reassign keys waits for the question and the dialog behind it.""" + + def test_the_question_waits_for_both(self, screen: Screen, conversion_hold: ConversionHold) -> None: + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + keys: Dict[ShortcutId, str] = {} + + def ask_to_reassign_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + keys.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert reassign.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_brings_the_dialog_back_first(screen: Screen) -> None: + reassign.cancel() + + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] + + def closing_the_dialog_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + ask_to_reassign_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_brings_the_dialog_back_first, + closing_the_dialog_hands_the_screen_over, + ).run() + + +class TestTheRowListeningAfterTheReassignQuestion: + """A row that reads as listening once Cancel answers the reassign question takes the next keys pressed.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a row listening again after the reassign question takes no keys", + ) + def test_the_same_keys_ask_again(self, screen: Screen) -> None: + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.REDO) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning again") + + +class TestTwoGesturesWaitingOnAHeldRebuild: + """A load and a close asked for while an edit is on its way each ask their question once it lands, in order.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_their_questions_come_one_after_the_other( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + def load_and_close_while_it_is_held(screen: Screen) -> None: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def the_load_asks_first(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the first question") + assert prompt.title() == screen.words(LOAD_TITLE) + assert len(screen.shown_windows()) == 1 + + def the_close_asks_next(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.title, screen.words(CLOSE_TITLE).__eq__, description="the second question") + assert len(screen.shown_windows()) == 1 + + def nothing_asks_after_them(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the questions answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_while_the_rebuild_is_held, + load_and_close_while_it_is_held, + the_load_asks_first, + the_close_asks_next, + nothing_asks_after_them, + leave_letting_it_go, + ).run() + + +class TestAFailedRebuildBeforeAClose: + """A rebuild that fails while a close waits on it reports the failure first, and the close asks after.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_first_and_the_question_after( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + standing: List[str] = [] + + def edit_a_removed_reconstruction_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + standing.append(reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_while_it_is_held(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_failure_comes_first(screen: Screen) -> None: + regeneration_hold.fail(RebuildBrokeError(REBUILD_FAILURE)) + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(RebuildBrokeError.__name__) + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def the_question_comes_once_the_failure_is_read(screen: Screen) -> None: + notice.dismiss() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + + def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_a_removed_reconstruction_while_the_rebuild_is_held, + close_while_it_is_held, + the_failure_comes_first, + the_question_comes_once_the_failure_is_read, + cancel_keeps_the_reconstruction_as_it_stood, + leave_letting_it_go, + ).run() + + +class TestASaveIntoAFolderThatTakesNothing: + """A save into a folder that takes no file shows its failure alone, and the reconstruction stays open.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_stands_alone_and_a_save_elsewhere_closes_it(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + locked = RECONSTRUCTIONS_DIRECTORY / LOCKED_FOLDER + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + locked.mkdir() + locked.chmod(READ_AND_ENTER) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + + def save_into_the_locked_folder(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing") + screen.answer_next_dialog(DialogKind.SAVE, locked / KEPT) + + prompt.save() + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(PermissionError.__name__) + assert screen.words(SAVE_FAILED) in notice.words() + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def dismissing_it_leaves_the_reconstruction_open(screen: Screen) -> None: + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the failure dismissed") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert not (locked / KEPT).exists() + + def a_save_elsewhere_closes_it(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + + screen.scenario( + remove_its_file, + save_into_the_locked_folder, + dismissing_it_leaves_the_reconstruction_open, + a_save_elsewhere_closes_it, + ).run() diff --git a/tests/screens/prompts/test_open_voice.py b/tests/screens/prompts/test_open_voice.py new file mode 100644 index 000000000..2e5580597 --- /dev/null +++ b/tests/screens/prompts/test_open_voice.py @@ -0,0 +1,258 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + edit_envelope, + expect_open, + marked, + raise_the_first_level, + titled, + voice_title, +) +from tests.suite.screens.steps.sequencer import double_click_voice, open_voice, voice_row +from tests.suite.screens.world import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +SAMPLE_ORDINAL: Final[int] = 0 +PERSONS_HOLD_FRAMES: Final[int] = 10 +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" +EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" +SAVE: Final[str] = "global.dialog.label.save" +DISCARD: Final[str] = "global.dialog.label.discard" +CANCEL: Final[str] = "global.dialog.label.cancel" +REMOVE_VOICE: Final[str] = "sequencer.voices.label.context_remove" +FADING: Final[str] = "12 8 4" + + +def sample_title(screen: Screen, *, unsaved: bool) -> str: + return voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=unsaved) + + +def leave_without_saving(screen: Screen) -> None: + """Exits, letting the project's changes go at the question the exit asks.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def open_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + + screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") + assert screen.tabs.front() is Tab.RECONSTRUCTIONS + + +class TestAVoiceDoubleClickedOverAnEditedReconstruction: + """A voice double-clicked while an edited reconstruction of its own stands open asks about the edits first.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_question_comes_first_and_cancel_keeps_the_reconstruction(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + edited = titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + stored: List[bytes] = [] + + def edit_the_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited) + + def the_double_click_asks_first(screen: Screen) -> None: + double_click_voice(screen, SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert prompt.title() == screen.words(EDIT_TITLE) + assert prompt.words() == screen.words(EDIT_MESSAGE) + assert prompt.answers() == (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)) + assert screen.title() == edited + + def cancel_keeps_the_reconstruction(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + assert screen.tabs.front() is Tab.SEQUENCER + + def discard_opens_the_voice(screen: Screen) -> None: + double_click_voice(screen, SONG_SAMPLE) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") + assert screen.tabs.front() is Tab.RECONSTRUCTIONS + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + edit_the_reconstruction, + the_double_click_asks_first, + cancel_keeps_the_reconstruction, + discard_opens_the_voice, + ).run() + + +class TestTheOpenVoiceGoesWithItsProject: + """The Reconstructions tab empties once the voice open on it leaves the project, or the project closes.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_removing_the_open_voice_empties_the_tab(self, screen: Screen) -> None: + voices = screen.sequencer.voices + menu = screen.context_menu + + def remove_it(screen: Screen) -> None: + open_the_sample(screen) + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(voice_row(screen, SONG_SAMPLE)) + screen.expect(menu.is_shown, bool, description="the voice's menu") + + menu.choose(screen.words(REMOVE_VOICE)) + + screen.expect(voices.names, [SONG_INSTRUMENT].__eq__, description="the sample gone") + assert not voices.remove_prompt.is_shown() + + def the_tab_empties(screen: Screen) -> None: + screen.expect( + screen.title, + titled(screen, marked(SONG.stem, unsaved=True)).__eq__, + description="nothing open beside the project", + ) + assert screen.reconstructions.file_line() == "" + + screen.scenario(remove_it, the_tab_empties, leave_without_saving).run() + + def test_closing_the_project_empties_the_tab(self, screen: Screen) -> None: + def close_the_project(screen: Screen) -> None: + open_the_sample(screen) + + screen.project.close() + + screen.expect(screen.title, titled(screen).__eq__, description="nothing open") + assert screen.reconstructions.file_line() == "" + assert screen.sequencer.voices.names() == [] + + screen.scenario(close_the_project).run() + + +class TestTheTabFollowsTheProjectsHistory: + """Undo and redo pressed on the Sequencer tab redraw the voice open on the Reconstructions tab.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_undo_and_redo_of_a_sample_edit(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + drawn: Dict[str, str] = {} + + def edit_the_sample(screen: Screen) -> None: + open_the_sample(screen) + drawn["standing"] = reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + drawn["edit"] = raise_the_first_level(screen, ChannelName.PULSE1, title=sample_title(screen, unsaved=True)) + + def undo_from_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["standing"].__eq__, + description="the edit undone", + ) + + def redo_from_the_sequencer(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.REDO) + + screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["edit"].__eq__, + description="the edit redone", + ) + assert screen.title() == sample_title(screen, unsaved=True) + + screen.scenario(edit_the_sample, undo_from_the_sequencer, redo_from_the_sequencer, leave_without_saving).run() + + def test_undo_of_an_instrument_edit_redraws_its_envelopes(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + drawn: Dict[str, str] = {} + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the instrument open") + drawn["standing"] = reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=titled(screen, marked(SONG.stem, unsaved=True)), + ) + + def undo_from_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["standing"].__eq__, + description="the edit undone", + ) + assert screen.title() == titled(screen, SONG.stem) + + screen.scenario(edit_the_instrument, undo_from_the_sequencer).run() + + +class TestAVoiceOpenedByADoubleClickHeldAsAPersonHoldsIt: + """A voice double-clicked opens as it stands, however long the second press is held.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a voice double-clicked with the second press held opens edited", + ) + def test_the_voice_opens_unchanged(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + drawn: List[Tuple[str, ...]] = [] + + def read_it_and_put_it_away(screen: Screen) -> None: + open_the_sample(screen) + drawn.append(tuple(reconstructions.envelope(channel, FeatureKey.VOLUME) for channel in PULSES)) + + reconstructions.close_from_menu() + + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the sample put away") + + def double_click_it_holding_the_second_press(screen: Screen) -> None: + screen.hand.double_click_held(voice_row(screen, SONG_SAMPLE), frames=PERSONS_HOLD_FRAMES) + + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + screen.frames(PERSONS_HOLD_FRAMES) + assert screen.title() == sample_title(screen, unsaved=False) + assert tuple(reconstructions.envelope(channel, FeatureKey.VOLUME) for channel in PULSES) == drawn[0] + + screen.scenario(read_it_and_put_it_away, double_click_it_holding_the_second_press).run() diff --git a/tests/screens/prompts/test_vanished.py b/tests/screens/prompts/test_vanished.py new file mode 100644 index 000000000..1cf314799 --- /dev/null +++ b/tests/screens/prompts/test_vanished.py @@ -0,0 +1,131 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + marked, + remove_from_the_browser, + stored_levels, + titled, +) +from tests.suite.screens.world import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +SETTLING_FRAMES: Final[int] = 10 +CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" +CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" +SAVE: Final[str] = "global.dialog.label.save" +CLOSE: Final[str] = "global.dialog.label.close" +CANCEL: Final[str] = "global.dialog.label.cancel" +NOT_APPLICABLE: Final[str] = "reconstructions.reconstruction.label.path_not_applicable" +RATE_SET_BY_THE_PROJECT: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_locked" + + +def edited_title(screen: Screen) -> str: + return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + +def take_its_file_away(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect(screen.title, edited_title(screen).__eq__, description="the reconstruction left unsaved") + + +class TestAReconstructionWhoseFileWasRemoved: + """The open reconstruction whose file the browser removed stays open with nowhere to save, and asks where. + + Closing it asks first. Save asks where to write it, a dismissed dialog brings the question back + once, and Cancel keeps it open. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_close_asks_where_a_dismissed_dialog_asks_once_and_cancel_keeps_it(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + standing: List[List[int]] = [] + + def remove_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, channel) for channel in PULSES) + + take_its_file_away(screen) + + assert reconstructions.file_line() == screen.words(NOT_APPLICABLE) + assert not reconstructions.can_save() + + def close_asks_first(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + assert prompt.words() == screen.words(CLOSE_MESSAGE) + assert prompt.answers() == (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)) + + def save_asks_where_and_a_dismissed_dialog_asks_once_more(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, None) + + prompt.save() + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + screen.frames(SETTLING_FRAMES) + assert len(prompt.shown_windows()) == 1 + (request,) = screen.dialog_requests() + assert request.kind is DialogKind.SAVE + assert request.initial_directory == RECONSTRUCTIONS_DIRECTORY + assert request.suggested_name == OPEN_RECONSTRUCTION.name + + def cancel_keeps_it_open(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == edited_title(screen) + assert len(screen.dialog_requests()) == 1 + + def save_where_asked_writes_it_and_closes(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert [stored_levels(kept, channel) for channel in PULSES] == standing + assert not OPEN_RECONSTRUCTION.exists() + + screen.scenario( + remove_it, + close_asks_first, + save_asks_where_and_a_dismissed_dialog_asks_once_more, + cancel_keeps_it_open, + save_where_asked_writes_it_and_closes, + ).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a reconstruction whose file was removed reads as a sample of the project", + ) + def test_the_locked_rate_says_nothing_of_a_project(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + take_its_file_away(screen) + + assert not reconstructions.can_retune() + assert reconstructions.retune_lock_explained() + assert reconstructions.retune_lock_words() != screen.words(RATE_SET_BY_THE_PROJECT) diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index 95ce5dd43..ca981fb6b 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -109,6 +109,25 @@ def double_click(self, item: Item) -> None: """Presses ``item`` twice in quick succession with the left button.""" self._press(item, MouseButton.LEFT, DOUBLE_PRESS) + def double_click_held( + self, + item: Item, + *, + frames: int, + ) -> None: + """Presses ``item`` twice with the left button, holding the second press down for ``frames`` frames. + + A person lets go of a click some frames after pressing it, and the double-click is answered + as the second press goes down, so whatever the answer brings under the pointer meets a held + button. + """ + self.hover(item) + self._outlast_a_double_click() + self._press_once(MouseButton.LEFT, HOLD_FRAMES) + self._press_once(MouseButton.LEFT, frames) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + def right_click(self, item: Item) -> None: """Presses ``item`` with the right button, the gesture that opens a context menu.""" self._press(item, MouseButton.RIGHT, SINGLE_PRESS) @@ -172,7 +191,7 @@ def turn_wheel_over( self._settle(SETTLE_FRAMES) def scroll_to_end(self, region: Item) -> None: - """Drags the grip of ``region``'s scrollbar to the bottom of its track, as a person reaches the end of a long list. + """Drags the grip of ``region``'s scrollbar to the bottom, as a person reaches the end of a long list. A list drawing only the rows in view settles its length as it scrolls, so the grip is dragged again while the end moves on, and a turn of the wheel takes up the pixels the grip's whole @@ -303,17 +322,25 @@ def _press( ) -> None: self.hover(item) self._outlast_a_double_click() - imgui_button = IMGUI_MOUSE_BUTTONS[button] for _ in range(count): - self._device.button_down(button) - self._settle(HOLD_FRAMES) - self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), f"The {button.name.lower()} button") - self._device.button_up(button) - self._settle(RELEASE_FRAMES) + self._press_once(button, HOLD_FRAMES) self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) + def _press_once( + self, + button: MouseButton, + frames: int, + ) -> None: + """Holds ``button`` down where the pointer stands for ``frames`` frames, then lets it go.""" + imgui_button = IMGUI_MOUSE_BUTTONS[button] + self._device.button_down(button) + self._settle(frames) + self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), f"The {button.name.lower()} button") + self._device.button_up(button) + self._settle(RELEASE_FRAMES) + def _outlast_a_double_click(self) -> None: """Waits until the last press lies further back than a double-click reaches, in the application's clock. diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py index e298342aa..450a53c44 100644 --- a/tests/suite/screens/dearpygui/items.py +++ b/tests/suite/screens/dearpygui/items.py @@ -13,6 +13,7 @@ MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" TEXT_TYPE: Final[str] = "mvAppItemType::mvText" +BUTTON_TYPE: Final[str] = "mvAppItemType::mvButton" TAG_SEPARATOR: Final[str] = "." @@ -189,7 +190,10 @@ def find_item( container: Item, matches: Callable[[Item], bool], ) -> Optional[Item]: - """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. Runs on the render thread.""" + """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. + + Runs on the render thread. + """ pending: List[Item] = [container] while pending: item = pending.pop(0) @@ -272,7 +276,10 @@ def read_value(item: Item) -> Any: def read_shown_texts(container: Item) -> Tuple[str, ...]: - """The words of every shown text item under ``container``, in the order they are drawn. Runs on the render thread.""" + """The words of every shown text item under ``container``, in the order they are drawn. + + Runs on the render thread. + """ return _texts(container, shown_only=True) @@ -284,6 +291,27 @@ def read_texts(container: Item) -> Tuple[str, ...]: return _texts(container, shown_only=False) +def read_shown_labels(container: Item, item_type: str) -> Tuple[str, ...]: + """The labels of every shown item of ``item_type`` under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + labels: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == item_type: + labels.append(read_label(item)) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(labels) + + def _texts(container: Item, *, shown_only: bool) -> Tuple[str, ...]: texts: List[str] = [] pending: List[Item] = [container] diff --git a/tests/suite/screens/holds.py b/tests/suite/screens/holds.py index 26816d74a..213dc4882 100644 --- a/tests/suite/screens/holds.py +++ b/tests/suite/screens/holds.py @@ -1,15 +1,20 @@ +import threading import time from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import AbstractSet, Final, Iterator, List, Protocol, Sequence +from typing import AbstractSet, Final, Iterator, List, Optional, Protocol, Sequence import pytest import sampletones_application.logic.main.sources.scan as scan_module import sampletones_core.reconstructions.converter.converter as converter_module +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.services.result import ServiceError from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.progress import STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, announce from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.stage import ReconstructionStage @@ -160,3 +165,60 @@ def _walk(self, root: Path) -> Iterator[Path]: while not self._signal.is_released(): yield root / HELD_ENTRY time.sleep(self._interval) + + +class RegenerationHold: + """Holds every rebuild of an edited channel on its worker before it computes, until the scenario releases it. + + The rebuild waits where the real one runs, so the application draws and answers while the edit + is on its way, and once released the real rebuild computes the edit's result. The hold lets + every rebuild through from its release on. A scenario standing for a rebuild that breaks lets + the held rebuilds go as that failure instead, which the service reports the way it reports a + rebuild that raised. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._failure: Optional[Exception] = None + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + rebuild = RegenerationService._run + + def held( + service: RegenerationService, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + self._wait() + if self._failure is not None: + service._emit(ServiceError(exception=self._failure)) + return + + rebuild(service, reconstruction, channel_name, features, heard) + + monkeypatch.setattr(RegenerationService, "_run", held) + + def waiting(self) -> int: + """How many rebuilds stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + self._released.set() + + def fail(self, failure: Exception) -> None: + """Lets every held rebuild, and every one asked for after, go as ``failure``.""" + self._failure = failure + self._released.set() + + def _wait(self) -> None: + with self._lock: + self._waiting += 1 + + self._released.wait() + with self._lock: + self._waiting -= 1 diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index 5e4a033d1..126d6831b 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -26,7 +26,7 @@ child_environment, display_backend, ) -from tests.suite.screens.holds import ConversionHold, Holds, ReleaseSignal, ScanHold +from tests.suite.screens.holds import ConversionHold, Holds, RegenerationHold, ReleaseSignal, ScanHold from tests.suite.screens.paths import FAILURE_SCREENSHOT, SCREENS_DIRECTORY from tests.suite.screens.render_thread import QueueRenderThread from tests.suite.screens.screen import Screen @@ -188,6 +188,18 @@ def scan_hold( return hold +@pytest.fixture +def regeneration_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> RegenerationHold: + """Holds every rebuild an edit of a channel asks for, until the scenario releases it.""" + hold = RegenerationHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + @pytest.fixture def screen_boundaries( output_device: OutputDevice, diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 19adb82f2..40d94896c 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -36,6 +36,7 @@ from tests.suite.screens.views.context_menu import ContextMenu from tests.suite.screens.views.display_settings import DisplaySettings from tests.suite.screens.views.instructions import Instructions +from tests.suite.screens.views.keyboard_settings import KeyboardSettings from tests.suite.screens.views.main import Main from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.notices import Notice @@ -85,12 +86,13 @@ def __init__( self.tabs = Tabs(bridge, hand) self.menu = MenuBar(bridge, language) self.display_settings = DisplaySettings(bridge, hand, self.menu) + self.keyboard_settings = KeyboardSettings(bridge, hand, self.menu) self.project = Project(bridge, hand, self.menu) self.main = Main(bridge, hand, language) self.context_menu = ContextMenu(bridge, hand) self.explorer = FileTree(bridge, hand, TAG_MAIN_EXPLORER_TREE) self.reconstructions = Reconstructions(bridge, hand, self.menu) - self.sequencer = Sequencer(bridge, self.menu) + self.sequencer = Sequencer(bridge, hand, self.menu) self.instructions = Instructions(bridge, hand) self.error_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_ERROR) self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py index 311ba228d..6bb7ea315 100644 --- a/tests/suite/screens/seeds.py +++ b/tests/suite/screens/seeds.py @@ -11,6 +11,11 @@ from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.fft import Window from sampletones_core.library.key import InstructionLibraryKey +from sampletones_core.project import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document from tests.suite.library import build_served_library @@ -19,6 +24,9 @@ CONFIG_FIELD: Final[str] = "config" RECORDING_SAMPLE_RATE: Final[int] = 44100 RECORDING_AMPLITUDE: Final[float] = 0.5 +STORED_RECORDING_SECONDS: Final[float] = 0.5 +STORED_RECORDING_FREQUENCY: Final[float] = 220.0 +AUDIO_PATH_FIELD: Final[str] = "audio_filepath" class Damage(StrEnum): @@ -83,6 +91,53 @@ def write(self) -> None: library.save_data(key, library.data[key]) +@dataclass(frozen=True) +class StoredReconstruction: + """The reconstruction the last release wrote, laid at ``destination`` the way this build writes it. + + It names the recording :func:`stored_recording` lays in the home, so it opens with its source. + """ + + destination: Path + + def write(self) -> None: + self.destination.parent.mkdir(parents=True, exist_ok=True) + _stored_reconstruction().save(self.destination) + + +@dataclass(frozen=True) +class StoredProject: + """A project holding the stored reconstruction as the sample ``sample`` and a hand-written ``instrument``.""" + + destination: Path + sample: str + instrument: str + + def write(self) -> None: + project = Project.create() + project.voices.append(Sample(name=self.sample, reconstruction=_stored_reconstruction())) + project.voices.append(Instrument(name=self.instrument)) + self.destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, self.destination) + + +def stored_recording() -> Recording: + """The recording the archived reconstruction names, laid where its relative path leads from the home. + + The application runs in the home, so a relative path the document names resolves there. + """ + document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) + return Recording( + destination=Path.cwd() / document[AUDIO_PATH_FIELD], + seconds=STORED_RECORDING_SECONDS, + frequency=STORED_RECORDING_FREQUENCY, + ) + + +def _stored_reconstruction() -> Reconstruction: + return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) + + def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at ``destination``.""" return CopiedFile( diff --git a/tests/suite/screens/steps/main.py b/tests/suite/screens/steps/main.py index 42204842c..e35afe5fc 100644 --- a/tests/suite/screens/steps/main.py +++ b/tests/suite/screens/steps/main.py @@ -1,8 +1,15 @@ +import operator +from functools import partial from pathlib import Path +from typing import Dict, Final, Sequence +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.items import Item from tests.suite.screens.screen import Screen +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 + def home_path(name: str) -> Path: """A path in the home the application was started in, which the explorer stands open on.""" @@ -35,3 +42,43 @@ def choose_from_the_row_menu(screen: Screen, path: Path, entry: str) -> None: screen.hand.right_click(row) screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") screen.context_menu.choose(entry) + + +def give_each_its_own_channel(screen: Screen, channels: Dict[Path, ChannelName]) -> None: + """Leaves each gathered recording the one channel ``channels`` names for it.""" + listing = screen.main.converter.list + for path, keep in channels.items(): + for channel in ChannelName: + if listing.channel_ticked(path, channel) != (channel == keep): + listing.tick(path, channel) + screen.expect( + partial(listing.channel_ticked, path, channel), + (channel == keep).__eq__, + description=f"{path.name} {channel}", + ) + + +def convert_alone( + screen: Screen, + recording: Path, + *, + channel: ChannelName, + replacing: Sequence[Path], +) -> None: + """Converts ``recording`` alone on ``channel``, and waits for the question the run's end asks. + + The recordings of ``replacing`` the list holds leave it first, and ``channel`` is the one + channel the run may sound. + """ + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + for gathered in replacing: + if converter.list.has_row(gathered): + converter.list.remove(gathered) + screen.expect(partial(converter.list.has_row, gathered), operator.not_, description=f"{gathered.name} gone") + + gather(screen, recording) + give_each_its_own_channel(screen, {recording: channel}) + converter.press_action() + + screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) diff --git a/tests/suite/screens/steps/reconstructions.py b/tests/suite/screens/steps/reconstructions.py new file mode 100644 index 000000000..f4a1f8b33 --- /dev/null +++ b/tests/suite/screens/steps/reconstructions.py @@ -0,0 +1,181 @@ +import operator +from pathlib import Path +from typing import Final, List, Optional + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.constants.symbols import TITLE_SEPARATOR +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.holds import RegenerationHold +from tests.suite.screens.screen import Screen + +APPLICATION_NAME: Final[str] = "global.dialog.title.main_window" +UNTITLED: Final[str] = "global.dialog.label.untitled" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" +REMOVE_RECONSTRUCTION: Final[str] = "reconstructions.browser.label.context_remove_reconstruction" +UNSAVED_MARK: Final[str] = "*" +LOUDEST: Final[int] = 15 +ITEM_SEPARATOR: Final[str] = " " +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" + + +def marked(name: str, *, unsaved: bool) -> str: + """A document's name as the title bar carries it, marked while it holds unsaved changes.""" + return f"{name}{UNSAVED_MARK}" if unsaved else name + + +def titled(screen: Screen, *documents: str) -> str: + """The window title naming ``documents`` after the application, each as the title bar marks it.""" + return TITLE_SEPARATOR.join((screen.words(APPLICATION_NAME), *documents)) + + +def reconstruction_row(screen: Screen, path: Path) -> Item: + """The browser's row of the reconstruction at ``path``, under the heading listing reconstructions by configuration. + + The heading is opened where it stands closed, so the row is in reach. + """ + browser = screen.reconstructions.browser + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + screen.expect(lambda: browser.is_open(heading), bool, description="the heading open") + + return screen.expect_item(lambda: browser.file_row(path), description=f"the row of {path.name}") + + +def load_from_the_browser(screen: Screen, path: Path) -> None: + """Double-clicks the browser's row of ``path``, which loads it once nothing unsaved stands in the way.""" + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + screen.reconstructions.browser.double_click(reconstruction_row(screen, path)) + + +def expect_open(screen: Screen, path: Path) -> None: + """Waits until the reconstruction stored at ``path`` is the one open.""" + screen.expect( + lambda: screen.reconstructions.shows_open(path), + bool, + description=f"{path.name} open", + ) + + +def edit_envelope( + screen: Screen, + *, + channel: ChannelName, + feature: FeatureKey, + sequence: str, + title: str, +) -> None: + """Types ``sequence`` over a channel's envelope and waits for the edit to land, which marks the title ``title``.""" + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + screen.reconstructions.type_envelope(channel, feature, sequence) + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") + + +def remove_from_the_browser(screen: Screen, path: Path) -> None: + """Removes the reconstruction at ``path`` through its row's menu, answering Remove to the question.""" + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + reconstructions.browser.right_click(reconstruction_row(screen, path)) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") + screen.context_menu.choose(screen.words(REMOVE_RECONSTRUCTION)) + screen.expect(reconstructions.remove_prompt.is_shown, bool, description="the question about removing") + + reconstructions.remove_prompt.confirm() + + screen.expect(reconstructions.remove_prompt.is_shown, operator.not_, description="the question gone") + screen.expect(path.exists, operator.not_, description=f"{path.name} gone") + + +def voice_title(screen: Screen, project: Optional[str], ordinal: int, voice: str, *, unsaved: bool) -> str: + """The window title while a project's sample stands open: the project, marked, and the sample in brackets. + + The sample is named by its place among the voices, counted from zero in hexadecimal, and its name. + """ + name = marked(project or screen.words(UNTITLED), unsaved=unsaved) + return titled(screen, f"{name} [{ordinal:02X}: {voice}]") + + +def raised(level: int) -> int: + """``level`` one step louder, wrapping past the loudest to silence.""" + return (level + 1) % (LOUDEST + 1) + + +def first_raised(sequence: str) -> str: + """A volume sequence as typed, its first level one step louder.""" + first, *rest = sequence.split(ITEM_SEPARATOR) + return ITEM_SEPARATOR.join((str(raised(int(first))), *rest)) + + +def first_level_raised(levels: List[int]) -> List[int]: + """The volume of each frame, the first frame's one step louder.""" + first, *rest = levels + return [raised(first), *rest] + + +def leading(levels: List[int], frames: List[int]) -> List[int]: + """The first of ``levels``, as many as ``frames`` holds: the frames a document had before an edit.""" + return levels[: len(frames)] + + +def stored_levels(path: Path, channel: ChannelName) -> List[int]: + """The volume each frame of a pulse ``channel`` plays at in the reconstruction stored at ``path``.""" + instructions = Reconstruction.load(path).instructions[channel] + return [instruction.volume for instruction in instructions if isinstance(instruction, PulseInstruction)] + + +def raise_the_first_level( + screen: Screen, + channel: ChannelName, + *, + title: str, +) -> str: + """Raises the first volume of ``channel`` one step and waits for the title to read ``title``. + + Returns: + str: The sequence typed. + """ + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + sequence = first_raised( + screen.expect( + lambda: screen.reconstructions.envelope(channel, FeatureKey.VOLUME), + bool, + description=f"the volume of {channel} drawn", + ) + ) + edit_envelope( + screen, + channel=channel, + feature=FeatureKey.VOLUME, + sequence=sequence, + title=title, + ) + return sequence + + +def converted(recording: Path) -> Path: + """The one reconstruction a run wrote for ``recording``, wherever below the reconstructions folder it went.""" + found = list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{recording.stem}{RECONSTRUCTION_SUFFIX}")) + assert len(found) == 1, found + return found[0] + + +def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, channel: ChannelName) -> str: + """Raises the first volume of ``channel`` one step and waits for its rebuild to stand held. + + Returns: + str: The sequence typed. + """ + reconstructions = screen.reconstructions + sequence = first_raised(reconstructions.envelope(channel, FeatureKey.VOLUME)) + + reconstructions.type_envelope(channel, FeatureKey.VOLUME, sequence) + + screen.expect(hold.waiting, bool, description="the rebuild held") + return sequence diff --git a/tests/suite/screens/steps/sequencer.py b/tests/suite/screens/steps/sequencer.py new file mode 100644 index 000000000..d733fd618 --- /dev/null +++ b/tests/suite/screens/steps/sequencer.py @@ -0,0 +1,28 @@ +from functools import partial +from typing import Final + +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.screen import Screen + +EDIT_VOICE: Final[str] = "sequencer.voices.label.context_edit" + + +def voice_row(screen: Screen, name: str) -> Item: + """The Voices card's row of the voice ``name``, on the Sequencer tab brought to the front.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices = screen.sequencer.voices + return screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + + +def double_click_voice(screen: Screen, name: str) -> None: + """Double-clicks the voice ``name``, which opens it on the Reconstructions tab.""" + screen.sequencer.voices.edit(voice_row(screen, name)) + + +def open_voice(screen: Screen, name: str) -> None: + """Opens the voice ``name`` on the Reconstructions tab through Edit on its row's menu.""" + row = voice_row(screen, name) + screen.sequencer.voices.right_click(row) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {name}") + screen.context_menu.choose(screen.words(EDIT_VOICE)) diff --git a/tests/suite/screens/views/display_settings.py b/tests/suite/screens/views/display_settings.py index 356ca0f13..ae6003564 100644 --- a/tests/suite/screens/views/display_settings.py +++ b/tests/suite/screens/views/display_settings.py @@ -3,10 +3,14 @@ from sampletones_application.tags.general import SUF_BUTTON from sampletones_application.tags.settings import ( TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, + TAG_SETTINGS_DISPLAY_BUTTON_KEEP, TAG_SETTINGS_DISPLAY_BUTTON_OK, + TAG_SETTINGS_DISPLAY_BUTTON_REVERT, + TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, TAG_SETTINGS_DISPLAY_DIALOG_DISCARD, TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN, ) from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand @@ -43,6 +47,26 @@ def vsync(self) -> bool: def toggle_vsync(self) -> None: self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) + def borderless(self) -> bool: + """Whether the borderless box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS))) + + def toggle_borderless(self) -> None: + """Clicks the borderless box, which changes the window mode and starts the countdown.""" + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS) + + def countdown_shown(self) -> bool: + """Whether the countdown asking to keep a new window mode stands.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN)).shown + + def keep(self) -> None: + """Presses Keep on the countdown.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_KEEP, SUF_BUTTON)) + + def revert(self) -> None: + """Presses Revert on the countdown.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, SUF_BUTTON)) + def cancel(self) -> None: self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, SUF_BUTTON)) diff --git a/tests/suite/screens/views/keyboard_settings.py b/tests/suite/screens/views/keyboard_settings.py new file mode 100644 index 000000000..a44efbdd2 --- /dev/null +++ b/tests/suite/screens/views/keyboard_settings.py @@ -0,0 +1,55 @@ +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + PRE_SETTINGS_KEYBINDINGS_ROW, + SUF_SETTINGS_KEYBINDINGS_SHORTCUT, + TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, + TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, + TAG_SETTINGS_KEYBINDINGS_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_label +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt + + +class KeyboardSettings: + """The Keyboard settings dialog: one row per action with the keys it answers to, and Cancel and OK.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.reassign_prompt = Prompt(bridge, hand, TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN) + + def open(self) -> None: + """Opens the dialog from View โ–ธ Keyboard settings.""" + self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS) + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_KEYBINDINGS_WINDOW)).shown + + def listen_for(self, shortcut_id: ShortcutId) -> None: + """Clicks the keys of ``shortcut_id``'s row, which listens for the next keys pressed.""" + cell = _shortcut_cell(shortcut_id) + self._hand.scroll_into_view(cell) + self._hand.click(cell) + + def keys_of(self, shortcut_id: ShortcutId) -> str: + """What the row of ``shortcut_id`` shows as its keys.""" + return self._bridge.ask(lambda: read_label(_shortcut_cell(shortcut_id))) + + def cancel(self) -> None: + self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, SUF_BUTTON)) + + +def _shortcut_cell(shortcut_id: ShortcutId) -> str: + return compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, shortcut_id.value, SUF_SETTINGS_KEYBINDINGS_SHORTCUT) diff --git a/tests/suite/screens/views/menus.py b/tests/suite/screens/views/menus.py index 227bf71d5..1526d9b37 100644 --- a/tests/suite/screens/views/menus.py +++ b/tests/suite/screens/views/menus.py @@ -4,7 +4,7 @@ from sampletones_application.tags.general import TAG_GLOBAL_WINDOW_MAIN from sampletones_application.utils.callbacks.queue import CallbackQueue from tests.suite.screens.dearpygui.bridge import Bridge -from tests.suite.screens.dearpygui.items import MENU_ITEM_TYPE, MENU_TYPE, find_labelled +from tests.suite.screens.dearpygui.items import MENU_ITEM_TYPE, MENU_TYPE, Item, find_labelled, read_item from tests.suite.screens.dearpygui.semantic import invoke @@ -30,23 +30,32 @@ def choose( item: MenuElements, ) -> None: """Chooses the entry ``item`` of the menu ``group``.""" - group_label = self._label(group) - item_label = self._label(item) - - def press() -> None: - menu = find_labelled( - TAG_GLOBAL_WINDOW_MAIN, - group_label, - item_type=MENU_TYPE, - ) - entry = find_labelled( - menu, - item_label, - item_type=MENU_ITEM_TYPE, - ) - invoke(entry, CallbackQueue.run) - - self._bridge.ask(press) + self._bridge.ask(lambda: invoke(self._entry(group, item), CallbackQueue.run)) + + def is_enabled( + self, + group: MenuElements, + item: MenuElements, + ) -> bool: + """Whether the entry ``item`` of the menu ``group`` answers a press.""" + return self._bridge.ask(lambda: read_item(self._entry(group, item)).enabled) + + def _entry( + self, + group: MenuElements, + item: MenuElements, + ) -> Item: + """The entry ``item`` of the menu ``group``. Runs on the render thread.""" + menu = find_labelled( + TAG_GLOBAL_WINDOW_MAIN, + self._label(group), + item_type=MENU_TYPE, + ) + return find_labelled( + menu, + self._label(item), + item_type=MENU_ITEM_TYPE, + ) def _label(self, element: MenuElements) -> str: return self._language[ diff --git a/tests/suite/screens/views/notices.py b/tests/suite/screens/views/notices.py index a323a1b5f..9aef829bc 100644 --- a/tests/suite/screens/views/notices.py +++ b/tests/suite/screens/views/notices.py @@ -1,9 +1,6 @@ -from typing import Tuple - from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_shown_texts -from tests.suite.screens.views.prompts import MissingPromptError, Prompt +from tests.suite.screens.views.prompts import Prompt class Notice: @@ -19,7 +16,6 @@ def __init__( hand: Hand, tag: str, ) -> None: - self._bridge = bridge self.prompt = Prompt(bridge, hand, tag) def is_shown(self) -> bool: @@ -31,12 +27,7 @@ def words(self) -> str: Raises: MissingPromptError: If no notice of this kind stands on the screen. """ - window = self.prompt.window() - if window is None: - raise MissingPromptError("No notice of this kind stands on the screen") - - texts: Tuple[str, ...] = self._bridge.ask(lambda: read_shown_texts(window.alias)) - return " ".join(texts) + return self.prompt.words() def dismiss(self) -> None: """Presses OK.""" diff --git a/tests/suite/screens/views/project.py b/tests/suite/screens/views/project.py index 5cfdb3342..893701971 100644 --- a/tests/suite/screens/views/project.py +++ b/tests/suite/screens/views/project.py @@ -72,6 +72,10 @@ def create(self) -> None: """Starts a new project from File โ–ธ New project.""" self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT) + def close(self) -> None: + """Closes the project from File โ–ธ Close project.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_CLOSE_PROJECT) + def save_as(self) -> None: """Saves the project from File โ–ธ Save project as, which asks where.""" self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS) diff --git a/tests/suite/screens/views/prompts.py b/tests/suite/screens/views/prompts.py index 9c510d521..b2e9c6a6a 100644 --- a/tests/suite/screens/views/prompts.py +++ b/tests/suite/screens/views/prompts.py @@ -1,10 +1,17 @@ -from typing import List, Optional +from typing import List, Optional, Tuple from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_BUTTON, SUF_BUTTON_CANCEL, SUF_BUTTON_OK, SUF_BUTTON_SAVE from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import WindowReading, is_tag_within, read_windows +from tests.suite.screens.dearpygui.items import ( + BUTTON_TYPE, + WindowReading, + is_tag_within, + read_shown_labels, + read_shown_texts, + read_windows, +) class MissingPromptError(AssertionError): @@ -44,6 +51,21 @@ def shown_windows(self) -> List[WindowReading]: def is_shown(self) -> bool: return self.window() is not None + def title(self) -> str: + """The title the prompt's window carries.""" + return self._standing().label + + def words(self) -> str: + """Everything the prompt says, its lines joined by spaces.""" + window = self._standing() + texts: Tuple[str, ...] = self._bridge.ask(lambda: read_shown_texts(window.alias)) + return " ".join(texts) + + def answers(self) -> Tuple[str, ...]: + """The buttons the prompt offers, read left to right.""" + window = self._standing() + return self._bridge.ask(lambda: read_shown_labels(window.alias, BUTTON_TYPE)) + def confirm(self) -> None: """Presses the button that says yes: Discard, Load, Exit and the like.""" self._press(SUF_BUTTON_OK) @@ -57,8 +79,11 @@ def cancel(self) -> None: self._press(SUF_BUTTON_CANCEL) def _press(self, suffix: str) -> None: + self._hand.click(compose_tag(self._standing().alias, suffix, SUF_BUTTON)) + + def _standing(self) -> WindowReading: window = self.window() if window is None: raise MissingPromptError(f"No prompt under '{self._tag}' stands on the screen") - self._hand.click(compose_tag(window.alias, suffix, SUF_BUTTON)) + return window diff --git a/tests/suite/screens/views/reconstructions.py b/tests/suite/screens/views/reconstructions.py index 13cb232e7..365a3b721 100644 --- a/tests/suite/screens/views/reconstructions.py +++ b/tests/suite/screens/views/reconstructions.py @@ -2,22 +2,43 @@ from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_TOOLTIP +from sampletones_application.tags.general import ( + SUF_GROUP, + SUF_TEXT, + SUF_TOOLTIP, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, +) +from sampletones_application.tags.graphs import SUF_GRAPH_RAW_DATA from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION, TAG_RECONSTRUCTIONS_BROWSER_TREE, + TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) +from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_texts +from tests.suite.screens.dearpygui.items import read_item, read_texts, read_value +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt OPEN_FILE_TOOLTIP = compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, SUF_TOOLTIP) class Reconstructions: - """The Reconstructions tab: the browser of saved reconstructions, and the one open beside it.""" + """The Reconstructions tab: the browser of saved reconstructions, and the one open beside it. + + Every question about unsaved changes to the open reconstruction stands under one kind of + prompt, whichever gesture asked it, so ``unsaved_prompt`` reads any of them, and its title and + words tell which. + """ def __init__( self, @@ -26,13 +47,34 @@ def __init__( menu: MenuBar, ) -> None: self._bridge = bridge + self._hand = hand self._menu = menu self.browser = FileTree(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_TREE) + self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION) + self.replaced_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED) + self.remove_prompt = Prompt(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION) + self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED) def open_from_menu(self) -> None: """Chooses Reconstruction โ–ธ Open, which asks for a file.""" self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_OPEN) + def close_from_menu(self) -> None: + """Chooses Reconstruction โ–ธ Close.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE) + + def save_from_menu(self) -> None: + """Chooses Reconstruction โ–ธ Save.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE) + + def can_close(self) -> bool: + """Whether Reconstruction โ–ธ Close answers.""" + return self._menu.is_enabled(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE) + + def can_save(self) -> bool: + """Whether Reconstruction โ–ธ Save answers.""" + return self._menu.is_enabled(MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE) + def open_file(self) -> str: """The whole path of the open reconstruction's file, as the hover over its shortened path shows it. @@ -43,3 +85,54 @@ def open_file(self) -> str: def shows_open(self, path: Path) -> bool: """Whether the reconstruction open is the one stored at ``path``.""" return self.open_file() == str(path.absolute()) + + def file_line(self) -> str: + """What the Reconstruction file line of Source settings shows: a shortened path, or a status.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE))) + + def can_retune(self) -> bool: + """Whether the NES frequency field takes a new rate.""" + return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)).enabled + + def retune_lock_explained(self) -> bool: + """Whether hovering the NES frequency field explains why it takes no new rate.""" + return self._bridge.ask( + lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED) + ).shown + + def retune_lock_words(self) -> str: + """What hovering the locked NES frequency field says.""" + return " ".join( + self._bridge.ask(lambda: read_texts(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)) + ) + + def offers_audition(self) -> bool: + """Whether the Audition switch stands, which it does while a hand-written instrument is open.""" + return self._bridge.ask( + lambda: read_item(compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP)) + ).shown + + def envelope( + self, + channel: ChannelName, + feature: FeatureKey, + ) -> str: + """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" + return str(self._bridge.ask(lambda: read_value(_envelope_field(channel, feature)))) + + def type_envelope( + self, + channel: ChannelName, + feature: FeatureKey, + sequence: str, + ) -> None: + """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses Enter.""" + self._hand.click(compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel)) + field = _envelope_field(channel, feature) + self._hand.scroll_into_view(field) + self._hand.replace_text(field, sequence) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + +def _envelope_field(channel: ChannelName, feature: FeatureKey) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH_RAW_DATA, SUF_TEXT) diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py index 4d225fa40..3f8216164 100644 --- a/tests/suite/screens/views/sequencer.py +++ b/tests/suite/screens/views/sequencer.py @@ -1,11 +1,13 @@ -from typing import Final, List +from typing import Final, List, Optional from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP -from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_DIALOG_REMOVE, TAG_SEQUENCER_VOICES_TABLE from tests.suite.screens.dearpygui.bridge import Bridge -from tests.suite.screens.dearpygui.items import read_item, read_label, read_table +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_table from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.prompts import Prompt NAME_COLUMN: Final[int] = 2 CELL_CONTENT: Final[int] = 0 @@ -14,17 +16,37 @@ class Voices: """The Voices card of the Sequencer: one row per voice of the project, its number and its name.""" - def __init__(self, bridge: Bridge) -> None: + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: self._bridge = bridge + self._hand = hand + self.remove_prompt = Prompt(bridge, hand, TAG_SEQUENCER_VOICES_DIALOG_REMOVE) def names(self) -> List[str]: """The names the rows show, top to bottom.""" def read() -> List[str]: - return [read_label(row[NAME_COLUMN][CELL_CONTENT]) for row in read_table(TAG_SEQUENCER_VOICES_TABLE)] + return [read_label(name) for name in _names()] return self._bridge.ask(read) + def row(self, name: str) -> Optional[Item]: + """The name cell of the first row reading ``name``, if one is drawn.""" + return self._bridge.ask(lambda: next((item for item in _names() if read_label(item) == name), None)) + + def edit(self, row: Item) -> None: + """Double-clicks ``row``, which opens its voice on the Reconstructions tab.""" + self._hand.scroll_into_view(row) + self._hand.double_click(row) + + def right_click(self, row: Item) -> None: + """Clicks ``row`` with the right button, which opens its menu.""" + self._hand.scroll_into_view(row) + self._hand.right_click(row) + class Playback: """Song playback as the Playback menu offers it: Play, which reads Pause while a song plays, and Stop.""" @@ -60,7 +82,13 @@ class Sequencer: def __init__( self, bridge: Bridge, + hand: Hand, menu: MenuBar, ) -> None: - self.voices = Voices(bridge) + self.voices = Voices(bridge, hand) self.playback = Playback(bridge, menu) + + +def _names() -> List[Item]: + """The name cell of every voice row, top to bottom. Runs on the render thread.""" + return [row[NAME_COLUMN][CELL_CONTENT] for row in read_table(TAG_SEQUENCER_VOICES_TABLE)] diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py index 834053e7a..8680f49aa 100644 --- a/tests/suite/screens/world.py +++ b/tests/suite/screens/world.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Optional, Protocol, Tuple +from pathlib import Path +from typing import Final, Optional, Protocol, Tuple from sampletones_application.config.managers.application import ApplicationConfigManager from sampletones_application.config.managers.state import ApplicationStateManager @@ -8,9 +9,19 @@ from sampletones_application.config.session.state.state import ApplicationState from sampletones_application.config.session.state.window import ViewportState from sampletones_core.configs import Config -from sampletones_shared.paths.user import CONFIG_PATH +from sampletones_shared.paths.user import CONFIG_PATH, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.screens.environment import SCREEN_SIZE -from tests.suite.screens.seeds import MiniLibrary +from tests.suite.screens.seeds import MiniLibrary, Recording, StoredProject, StoredReconstruction, stored_recording + +OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" +OTHER_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Other.stn" +SONG: Final[Path] = PROJECTS_DIRECTORY / "Song.stp" +SONG_SAMPLE: Final[str] = "Lead" +SONG_INSTRUMENT: Final[str] = "Pad" +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DOCUMENT_RECORDING_SECONDS: Final[float] = 0.3 +DOCUMENT_RECORDING_FREQUENCY: Final[float] = 220.0 class HomeFile(Protocol): @@ -97,3 +108,29 @@ def lived_in_world() -> World: config=None, files=(), ) + + +def documents_world() -> World: + """A home ready to convert that holds the documents a user puts away and replaces. + + It holds two reconstructions, :data:`OPEN_RECONSTRUCTION` and :data:`OTHER_RECONSTRUCTION`, + with the recording they name; the project :data:`SONG` with a sample and an instrument; and + two recordings to convert, :data:`BASS` and :data:`LEAD`, in the home itself. + """ + recordings = tuple( + Recording( + destination=Path.cwd() / name, + seconds=DOCUMENT_RECORDING_SECONDS, + frequency=DOCUMENT_RECORDING_FREQUENCY, + ) + for name in (BASS, LEAD) + ) + return converting_world( + ( + *recordings, + StoredReconstruction(OPEN_RECONSTRUCTION), + StoredReconstruction(OTHER_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ) + ) From bcaa0de97622436cf8811f9383ca0b7e9ad85745 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 16:26:13 +0200 Subject: [PATCH 10/19] Proved: the Reconstructions and Instructions tabs' playback, envelopes, stems and edits on a live screen --- docs/development/application/screens.md | 12 +- docs/development/bugs-and-todos.md | 7 + tests/screens/instructions/__init__.py | 0 tests/screens/instructions/conftest.py | 9 + tests/screens/instructions/test_library.py | 64 +++ tests/screens/instructions/test_waveform.py | 54 ++ tests/screens/main/test_library.py | 26 +- .../prompts/test_closing_over_a_modal.py | 4 +- tests/screens/prompts/test_every_door.py | 22 +- tests/screens/prompts/test_load_chain.py | 12 +- .../screens/prompts/test_modal_over_modal.py | 6 +- tests/screens/prompts/test_open_voice.py | 17 +- tests/screens/reconstructions/__init__.py | 0 tests/screens/reconstructions/conftest.py | 9 + tests/screens/reconstructions/test_edits.py | 276 +++++++++ .../reconstructions/test_instruments.py | 536 ++++++++++++++++++ tests/screens/reconstructions/test_player.py | 481 ++++++++++++++++ tests/screens/reconstructions/test_stems.py | 268 +++++++++ tests/suite/screens/application.py | 2 + tests/suite/screens/boundaries/reveals.py | 55 ++ tests/suite/screens/boundaries/spawns.py | 23 +- tests/suite/screens/dearpygui/hand.py | 120 +++- tests/suite/screens/dearpygui/items.py | 23 + tests/suite/screens/holds.py | 10 +- tests/suite/screens/plugin.py | 9 + tests/suite/screens/screen.py | 27 + tests/suite/screens/seeds.py | 67 ++- tests/suite/screens/steps/instructions.py | 41 ++ tests/suite/screens/steps/reconstructions.py | 8 +- tests/suite/screens/views/bar_graph.py | 121 ++++ tests/suite/screens/views/browsers.py | 9 +- tests/suite/screens/views/instructions.py | 26 +- tests/suite/screens/views/instruments.py | 125 ++++ tests/suite/screens/views/reconstructions.py | 68 +-- tests/suite/screens/views/stems.py | 80 +++ tests/suite/screens/views/waveform.py | 141 +++++ tests/suite/screens/world.py | 51 +- 37 files changed, 2683 insertions(+), 126 deletions(-) create mode 100644 tests/screens/instructions/__init__.py create mode 100644 tests/screens/instructions/conftest.py create mode 100644 tests/screens/instructions/test_library.py create mode 100644 tests/screens/instructions/test_waveform.py create mode 100644 tests/screens/reconstructions/__init__.py create mode 100644 tests/screens/reconstructions/conftest.py create mode 100644 tests/screens/reconstructions/test_edits.py create mode 100644 tests/screens/reconstructions/test_instruments.py create mode 100644 tests/screens/reconstructions/test_player.py create mode 100644 tests/screens/reconstructions/test_stems.py create mode 100644 tests/suite/screens/boundaries/reveals.py create mode 100644 tests/suite/screens/steps/instructions.py create mode 100644 tests/suite/screens/views/bar_graph.py create mode 100644 tests/suite/screens/views/instruments.py create mode 100644 tests/suite/screens/views/stems.py create mode 100644 tests/suite/screens/views/waveform.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index a322def14..5086a2ce4 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -131,8 +131,10 @@ brings under a button still down. A gesture confirms that it arrived. Before a button goes down, the control must report the pointer resting on it: DearPyGui calls a control visible while the region it scrolls in clips it, and a press there lands -on something else. While a button or a named key is held, the application must read it as down. A press -lost on the way fails where it was lost. +on something else. While a button or a named key is held, the application must read it as down within a +few frames, since a busy display hands input on late. Each step of a drag waits until the application +reads the pointer where the step put it, and the pointer rests at the end before the button comes up, as +a person stops before letting go. A press lost on the way fails where it was lost. DearPyGui reports a position alone for a menu entry, so choosing one runs its callback the way a click does, through the queue's own error reporting. @@ -150,7 +152,11 @@ and a scenario reads as the gestures a user makes. - **Audio.** The default output device plays into silence in real time, so playback runs and nothing is heard. A scenario can start on a machine offering no device at all. - **Programs.** An audit hook refuses every program the application tries to start: a file manager, a - browser or a dialog tool would open on the desktop around the run. A shared library lookup passes. + browser or a dialog tool would open on the desktop around the run. A shared library lookup passes, and + so do the stand-ins below. +- **The file manager.** Stand-in programs lead the search path: `xdg-mime` names a file manager, and that + file manager and `xdg-open` note each path they are asked to show. The application's own reveal code + runs, and `screen.revealed()` reads what it showed. - **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request a title bar's close button sends. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 129daa8b1..77e0ed6fe 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -195,3 +195,10 @@ currently out of line. An entry leaves when the code meets the contract again. * Two closes before the first is answered ask twice: each close puts its question in line, so Cancel on the first brings the second, and so does a close made twice while an edit is on its way. `tests/screens/prompts/test_closing_over_a_modal.py` reproduces it as a known failure. +* A reconstruction whose recording is missing draws a flat original line beside the reconstruction, + where the waveform shows the approximation on its own. `tests/screens/reconstructions/test_player.py` + reproduces it as a known failure. +* With an instrument open on the Reconstructions tab, the note keys take Ctrl+Z, Ctrl+S and every + combination ending in a note key: the instruments panel answers a key whatever modifiers are held, so + Undo, Save and the rest never reach their shortcuts. `tests/screens/reconstructions/test_instruments.py` + reproduces it as a known failure. diff --git a/tests/screens/instructions/__init__.py b/tests/screens/instructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/instructions/conftest.py b/tests/screens/instructions/conftest.py new file mode 100644 index 000000000..90d4f06a5 --- /dev/null +++ b/tests/screens/instructions/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.world import World, converting_world + + +@pytest.fixture +def world() -> World: + """The home every Instructions scenario starts in: a small library built for one-worker settings.""" + return converting_world(()) diff --git a/tests/screens/instructions/test_library.py b/tests/screens/instructions/test_library.py new file mode 100644 index 000000000..8605a3c7b --- /dev/null +++ b/tests/screens/instructions/test_library.py @@ -0,0 +1,64 @@ +from functools import partial +from typing import Dict, Final, Tuple + +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName, GeneratorName +from tests.suite.screens.dearpygui.items import Item, read_theme, read_theme_colors +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.instructions import load_library +from tests.suite.screens.world import one_worker_config + +GENERATOR_CHANNELS: Final[Dict[GeneratorName, ChannelName]] = { + GeneratorName.PULSE: ChannelName.PULSE1, + GeneratorName.TRIANGLE: ChannelName.TRIANGLE, + GeneratorName.NOISE: ChannelName.NOISE, +} + + +def line_color(screen: Screen, line: str) -> Tuple[float, ...]: + """The color the theme bound to ``line`` draws it in.""" + + def read() -> Tuple[float, ...]: + theme = read_theme(line) + assert theme is not None, f"no theme is bound to '{line}'" + return read_theme_colors(theme)[0] + + return screen.bridge.ask(read) + + +def channel_colors(screen: Screen, channel: ChannelName) -> Tuple[Tuple[float, ...], ...]: + """The colors the theme every view naming ``channel`` paints from holds.""" + return screen.bridge.ask(lambda: read_theme_colors(CHANNEL_THEME_TAGS[channel])) + + +class TestTheGeneratorsOfALibrary: + """A library lists a row per generator, each drawing its fragment's waveform and spectrum in its channel's color.""" + + def test_each_generator_draws_in_its_channels_color(self, screen: Screen) -> None: + instructions = screen.instructions + tree = instructions.library.tree + waveform = instructions.waveform + rows: Dict[GeneratorName, Item] = {} + + def a_row_per_generator(screen: Screen) -> None: + load_library(screen, one_worker_config()) + + for generator in GeneratorName: + rows[generator] = screen.expect_item( + partial(tree.generator_row, generator), description=f"the {generator} row" + ) + + def each_draws_in_its_color(screen: Screen) -> None: + colors: Dict[GeneratorName, Tuple[float, ...]] = {} + for generator, row in rows.items(): + tree.click(row) + + lines = screen.expect(waveform.series, bool, description=f"the {generator} waveform") + assert len(lines) == 1 + screen.expect(instructions.spectrum_bands, bool, description=f"the {generator} spectrum") + colors[generator] = line_color(screen, lines[0]) + assert colors[generator] in channel_colors(screen, GENERATOR_CHANNELS[generator]) + + assert len(set(colors.values())) == len(GeneratorName) + + screen.scenario(a_row_per_generator, each_draws_in_its_color).run() diff --git a/tests/screens/instructions/test_waveform.py b/tests/screens/instructions/test_waveform.py new file mode 100644 index 000000000..932cf0242 --- /dev/null +++ b/tests/screens/instructions/test_waveform.py @@ -0,0 +1,54 @@ +from functools import partial +from typing import Final + +from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY +from sampletones_core.constants.enums import GeneratorName +from tests.suite.screens.dearpygui.items import read_label +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.instructions import load_library +from tests.suite.screens.world import one_worker_config + +LISTENING_FRAMES: Final[int] = 30 +ZOOM_NOTCHES: Final[int] = 3 +PAUSE: Final[str] = "global.menu.label.item_playback_pause" + + +def show_the_pulse(screen: Screen) -> None: + instructions = screen.instructions + tree = instructions.library.tree + load_library(screen, one_worker_config()) + row = screen.expect_item(partial(tree.generator_row, GeneratorName.PULSE), description="the pulse row") + + tree.click(row) + + screen.expect(instructions.waveform.series, bool, description="the pulse drawn") + + +class TestTheFragmentWaveform: + """A click on the fragment's waveform sounds it, and a drag across a narrowed view sounds nothing. + + A fragment lasts a few frames, so what plays is read on every frame the gesture spans. + """ + + def test_a_click_sounds_it_and_a_drag_does_not(self, screen: Screen) -> None: + waveform = screen.instructions.waveform + playing = screen.words(PAUSE) + + def a_drag_sounds_nothing(screen: Screen) -> None: + show_the_pulse(screen) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + with screen.record(partial(read_label, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) as recording: + waveform.drag(0.6, 0.4) + screen.frames(LISTENING_FRAMES) + + assert playing not in recording.values() + + def a_click_sounds_it(screen: Screen) -> None: + with screen.record(partial(read_label, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) as recording: + waveform.click(0.5) + screen.frames(LISTENING_FRAMES) + + assert playing in recording.values() + + screen.scenario(a_drag_sounds_nothing, a_click_sounds_it).run() diff --git a/tests/screens/main/test_library.py b/tests/screens/main/test_library.py index 82b6bfbcc..f4f5dcc87 100644 --- a/tests/screens/main/test_library.py +++ b/tests/screens/main/test_library.py @@ -1,6 +1,5 @@ import operator from functools import partial -from pathlib import Path from typing import Final, List import dearpygui.dearpygui as dpg @@ -9,14 +8,13 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_core.configs import Config from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.fft import Window -from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.structures.tree.node import GeneratorNode from sampletones_shared.paths.user import LIBRARY_DIRECTORY from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.dearpygui.items import Item, find_item from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import MiniLibrary, archived_library +from tests.suite.screens.steps.instructions import library_path, library_row, open_library_row from tests.suite.screens.steps.main import home_path from tests.suite.screens.world import World, one_worker_config, screen_filling_state @@ -40,11 +38,6 @@ def quick_config() -> Config: return config.model_copy(update={"library": library}) -def library_path(config: Config) -> Path: - key = InstructionLibraryKey.create(config.library, Window.from_config(config)) - return LIBRARY_DIRECTORY / key.filename - - def library_world(config: Config, *, built: bool) -> World: state = screen_filling_state().model_copy(update={"advanced_settings": True}) return World( @@ -55,21 +48,6 @@ def library_world(config: Config, *, built: bool) -> World: ) -def library_row(screen: Screen, path: Path) -> Item: - library = screen.instructions.library - return screen.expect_item(partial(library.row, path.name), description=f"the row of {path.name}") - - -def open_library_row(screen: Screen, row: Item) -> None: - """Opens a library's row, which is the click that loads it, closing it first where it stands open.""" - tree = screen.instructions.library.tree - if tree.is_open(row): - tree.open_by_click(row) - screen.expect(partial(tree.is_open, row), operator.not_, description="the row closed") - - tree.open_by_click(row) - - def generator_rows(screen: Screen, row: Item) -> List[Item]: def read() -> List[Item]: found: List[Item] = [] @@ -190,7 +168,7 @@ def cancel_keeps_it(screen: Screen) -> None: class TestAnotherLibraryFolder: - """Advanced settings pointed at another folder lists its libraries; pointed back, the first library returns loaded.""" + """Advanced settings pointed at another folder lists its libraries; pointed back, the first returns loaded.""" @pytest.fixture def world(self) -> World: diff --git a/tests/screens/prompts/test_closing_over_a_modal.py b/tests/screens/prompts/test_closing_over_a_modal.py index 6fc7b260c..6180410ae 100644 --- a/tests/screens/prompts/test_closing_over_a_modal.py +++ b/tests/screens/prompts/test_closing_over_a_modal.py @@ -234,7 +234,7 @@ def edit_and_close_while_the_rebuild_is_held(screen: Screen) -> None: assert screen.shown_windows() == () def edit_again_after_the_close(screen: Screen) -> None: - reconstructions.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) + reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) screen.frames(SETTLING_FRAMES) assert screen.shown_windows() == () @@ -246,7 +246,7 @@ def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: prompt.cancel() screen.expect(prompt.is_shown, operator.not_, description="the question answered") screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), typed[0].__eq__, description="the field drawing the edit that landed", ) diff --git a/tests/screens/prompts/test_every_door.py b/tests/screens/prompts/test_every_door.py index df5986cf9..53163404c 100644 --- a/tests/screens/prompts/test_every_door.py +++ b/tests/screens/prompts/test_every_door.py @@ -149,7 +149,7 @@ def standing(screen: Screen) -> Standing: return Standing( title=screen.title(), file_line=reconstructions.file_line(), - volume=reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + volume=reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), windows=tuple(window.alias for window in screen.shown_windows()), dialogs=len(screen.dialog_requests()), ) @@ -276,7 +276,7 @@ def test_no_door_asks_and_the_exit_asks_about_the_project(self, screen: Screen) def open_the_sample(screen: Screen) -> None: open_voice(screen, SONG_SAMPLE) expect_title(screen, sample_open()) - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] def edit_the_sample(screen: Screen) -> None: open_voice(screen, SONG_SAMPLE) @@ -303,7 +303,7 @@ def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: def another_voice_opens_at_once(screen: Screen) -> None: knock(screen, Door.VOICE_MENU, voice=SONG_INSTRUMENT) - screen.expect(reconstructions.offers_audition, bool, description="the instrument open") + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") assert screen.title() == titled(screen, marked(SONG.stem, unsaved=True)) nothing_asked(screen) open_the_sample(screen) @@ -366,16 +366,16 @@ def test_no_door_asks_and_close_stands_aside(self, screen: Screen) -> None: def open_the_instrument(screen: Screen) -> None: open_voice(screen, SONG_INSTRUMENT) - screen.expect(reconstructions.offers_audition, bool, description="the instrument open") + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), FADING.__eq__, description="its edit drawn", ) def edit_the_instrument(screen: Screen) -> None: open_voice(screen, SONG_INSTRUMENT) - screen.expect(reconstructions.offers_audition, bool, description="the instrument open") + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") edit_envelope(screen, channel=ChannelName.PULSE1, feature=FeatureKey.VOLUME, sequence=FADING, title=edited) @@ -383,7 +383,7 @@ def the_browser_loads_at_once(screen: Screen) -> None: knock(screen, Door.BROWSER, voice=SONG_SAMPLE) expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) - assert not reconstructions.offers_audition() + assert not reconstructions.instruments.offers_audition() nothing_asked(screen) open_the_instrument(screen) @@ -400,7 +400,7 @@ def a_sample_opens_at_once(screen: Screen) -> None: knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True)) - assert not reconstructions.offers_audition() + assert not reconstructions.instruments.offers_audition() nothing_asked(screen) open_the_instrument(screen) @@ -410,8 +410,8 @@ def close_has_nothing_to_close(screen: Screen) -> None: screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) nothing_asked(screen) - assert reconstructions.offers_audition() - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING + assert reconstructions.instruments.offers_audition() + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING assert screen.title() == edited def the_conversion_loads_at_once(screen: Screen) -> None: @@ -432,7 +432,7 @@ def step(screen: Screen) -> None: prompt.cancel() screen.expect(prompt.is_shown, operator.not_, description="the question taken back") screen.frames(SETTLING_FRAMES) - assert reconstructions.offers_audition() + assert reconstructions.instruments.offers_audition() step.__name__ = f"{door.value}_asks_about_the_project" return step diff --git a/tests/screens/prompts/test_load_chain.py b/tests/screens/prompts/test_load_chain.py index 14eab1af1..275e955c0 100644 --- a/tests/screens/prompts/test_load_chain.py +++ b/tests/screens/prompts/test_load_chain.py @@ -101,7 +101,7 @@ def cancel_keeps_the_edited_one_open(screen: Screen) -> None: screen.expect(prompt.is_shown, operator.not_, description="the question gone") assert screen.title() == edited_title(screen) assert reconstructions.shows_open(OPEN_RECONSTRUCTION) - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] def save_writes_the_changes_and_loads_the_next(screen: Screen) -> None: @@ -179,7 +179,7 @@ def convert_and_load(screen: Screen) -> None: expect_open(screen, converted(home_path(BASS))) drawn["converted"] = screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), bool, description="Pulse 1's volume drawn", ) @@ -213,7 +213,7 @@ def cancel_keeps_the_edits(screen: Screen) -> None: screen.expect(prompt.is_shown, operator.not_, description="the question gone") assert screen.title() == titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)) - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["edit"] + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["edit"] def discard_loads_what_the_run_wrote(screen: Screen) -> None: convert_over_it(screen) @@ -225,7 +225,7 @@ def discard_loads_what_the_run_wrote(screen: Screen) -> None: titled(screen, converted(home_path(BASS)).name).__eq__, description="the conversion loaded", ) - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["converted"] + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["converted"] screen.scenario( convert_and_load, @@ -249,7 +249,7 @@ def test_the_file_changed_on_disk_loads_as_it_stands(self, screen: Screen) -> No def change_the_file_behind_it(screen: Screen) -> None: expect_open(screen, OPEN_RECONSTRUCTION) - standing.append(reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) reconstructions.save_from_menu() screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") @@ -260,7 +260,7 @@ def reloading_asks_nothing(screen: Screen) -> None: load_from_the_browser(screen, OPEN_RECONSTRUCTION) screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), standing[0].__eq__, description="the file as it now stands", ) diff --git a/tests/screens/prompts/test_modal_over_modal.py b/tests/screens/prompts/test_modal_over_modal.py index 8e2cfd39e..b61ca6b14 100644 --- a/tests/screens/prompts/test_modal_over_modal.py +++ b/tests/screens/prompts/test_modal_over_modal.py @@ -327,7 +327,7 @@ def nothing_asks_after_them(screen: Screen) -> None: screen.frames(SETTLING_FRAMES) assert screen.shown_windows() == () assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] def leave_letting_it_go(screen: Screen) -> None: screen.press_shortcut(ShortcutId.EXIT) @@ -372,7 +372,7 @@ def edit_a_removed_reconstruction_while_the_rebuild_is_held(screen: Screen) -> N titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, description="the reconstruction left unsaved", ) - standing.append(reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) @@ -403,7 +403,7 @@ def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: screen.expect(prompt.is_shown, operator.not_, description="the question answered") screen.frames(SETTLING_FRAMES) assert screen.shown_windows() == () - assert reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] def leave_letting_it_go(screen: Screen) -> None: screen.press_shortcut(ShortcutId.EXIT) diff --git a/tests/screens/prompts/test_open_voice.py b/tests/screens/prompts/test_open_voice.py index 2e5580597..c9c279955 100644 --- a/tests/screens/prompts/test_open_voice.py +++ b/tests/screens/prompts/test_open_voice.py @@ -164,7 +164,7 @@ def test_undo_and_redo_of_a_sample_edit(self, screen: Screen) -> None: def edit_the_sample(screen: Screen) -> None: open_the_sample(screen) - drawn["standing"] = reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + drawn["standing"] = reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) drawn["edit"] = raise_the_first_level(screen, ChannelName.PULSE1, title=sample_title(screen, unsaved=True)) @@ -174,7 +174,7 @@ def undo_from_the_sequencer(screen: Screen) -> None: screen.press_shortcut(ShortcutId.UNDO) screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), drawn["standing"].__eq__, description="the edit undone", ) @@ -183,7 +183,7 @@ def redo_from_the_sequencer(screen: Screen) -> None: screen.press_shortcut(ShortcutId.REDO) screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), drawn["edit"].__eq__, description="the edit redone", ) @@ -198,7 +198,7 @@ def test_undo_of_an_instrument_edit_redraws_its_envelopes(self, screen: Screen) def edit_the_instrument(screen: Screen) -> None: open_voice(screen, SONG_INSTRUMENT) screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the instrument open") - drawn["standing"] = reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + drawn["standing"] = reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) edit_envelope( screen, @@ -214,7 +214,7 @@ def undo_from_the_sequencer(screen: Screen) -> None: screen.press_shortcut(ShortcutId.UNDO) screen.expect( - lambda: reconstructions.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), drawn["standing"].__eq__, description="the edit undone", ) @@ -241,7 +241,7 @@ def test_the_voice_opens_unchanged(self, screen: Screen) -> None: def read_it_and_put_it_away(screen: Screen) -> None: open_the_sample(screen) - drawn.append(tuple(reconstructions.envelope(channel, FeatureKey.VOLUME) for channel in PULSES)) + drawn.append(tuple(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME) for channel in PULSES)) reconstructions.close_from_menu() @@ -253,6 +253,9 @@ def double_click_it_holding_the_second_press(screen: Screen) -> None: screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") screen.frames(PERSONS_HOLD_FRAMES) assert screen.title() == sample_title(screen, unsaved=False) - assert tuple(reconstructions.envelope(channel, FeatureKey.VOLUME) for channel in PULSES) == drawn[0] + assert ( + tuple(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME) for channel in PULSES) + == drawn[0] + ) screen.scenario(read_it_and_put_it_away, double_click_it_holding_the_second_press).run() diff --git a/tests/screens/reconstructions/__init__.py b/tests/screens/reconstructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/conftest.py b/tests/screens/reconstructions/conftest.py new file mode 100644 index 000000000..c5dcbbd02 --- /dev/null +++ b/tests/screens/reconstructions/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.world import World, playing_world + + +@pytest.fixture +def world() -> World: + """The home every Reconstructions scenario starts in: reconstructions that play, and a project.""" + return playing_world() diff --git a/tests/screens/reconstructions/test_edits.py b/tests/screens/reconstructions/test_edits.py new file mode 100644 index 000000000..fd005a9ab --- /dev/null +++ b/tests/screens/reconstructions/test_edits.py @@ -0,0 +1,276 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.holds import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import save_project_as +from tests.suite.screens.steps.reconstructions import ( + expect_open, + marked, + raise_the_first_level, + stored_levels, + titled, + voice_title, +) +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.views.bar_graph import BarGraph +from tests.suite.screens.views.instruments import Instruments +from tests.suite.screens.world import SHORT_RECONSTRUCTION, SONG, SONG_SAMPLE, STEMS_RECONSTRUCTION + +DRAGGED_ITEM: Final[int] = 5 +FIRST_ITEM: Final[int] = 1 +QUIET: Final[float] = 4.0 +LOUDEST: Final[int] = 15 +ARPEGGIO_STEP: Final[float] = 12.0 +ARPEGGIO_FLOOR: Final[int] = -128 +ARPEGGIO_CEILING: Final[int] = 127 +SAMPLE_ORDINAL: Final[int] = 0 +LAST_RECORDING: Final[str] = "2" +SETTLE_FRAMES: Final[int] = 20 +NEAR_THE_START: Final[float] = 0.05 +ZOOM_NOTCHES: Final[int] = 5 +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" + + +def items(instruments: Instruments, channel: ChannelName, feature: FeatureKey) -> List[int]: + return [int(item) for item in instruments.envelope(channel, feature).split()] + + +def dragged( + graph: BarGraph, + standing: List[int], + *, + end: float, + bounds: Tuple[int, int], +) -> Tuple[int, List[int]]: + """Drags the bar of the item :data:`DRAGGED_ITEM` to ``end``, and returns the item it moved and the items it leaves. + + The item the plot puts under the release takes the value there, brought within ``bounds``, and + every other item stands as it did. + """ + release: Point = graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=end) + index, value = graph.item_under(release) + low, high = bounds + expected = [*standing] + expected[index] = min(max(round(value), low), high) + return index, expected + + +def expect_items( + screen: Screen, instruments: Instruments, channel: ChannelName, feature: FeatureKey, expected: List[int] +) -> None: + screen.expect( + lambda: items(instruments, channel, feature), + expected.__eq__, + description=f"{channel} {feature} as the edits left it", + ) + + +def leave_letting_it_go(screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestADragAndARemovalAtOnce: + """A volume dragged and a recording removed while the drag is on its way both land.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_both_land(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + stems = reconstructions.stems + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] + + def drag_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + + moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def remove_a_recording_meanwhile(screen: Screen) -> None: + stems.remove(LAST_RECORDING) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + + stems.remove_prompt.confirm() + + screen.expect(stems.remove_prompt.is_shown, operator.not_, description="the question answered") + assert stems.has_row(LAST_RECORDING) + + def both_land(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(lambda: stems.has_row(LAST_RECORDING), operator.not_, description="the recording gone") + index, expected = moved[0] + screen.expect( + lambda: items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1], + expected[: index + 1].__eq__, + description="the drag landed", + ) + + screen.scenario( + drag_while_the_rebuild_is_held, remove_a_recording_meanwhile, both_land, leave_letting_it_go + ).run() + + +class TestADragAndAnUndoAtOnce: + """Undo pressed while a drag of a sample is on its way undoes the drag, and the edit saved before it stays. + + Consecutive edits of one sample run together into one entry of the history, and a save closes + the run, so the edit before the drag stands in an entry of its own. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_the_drag_is_undone(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + edited: List[List[int]] = [] + + def edit_the_sample_and_save(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + screen.expect( + screen.title, + voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False).__eq__, + description="the sample open", + ) + regeneration_hold.release() + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True), + ) + edited.append(items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)) + + save_project_as(screen, SAVED_SONG) + + def drag_and_undo_while_the_rebuild_is_held(screen: Screen) -> None: + regeneration_hold.hold_again() + screen.hand.scroll_into_view(graph.plot) + release = graph.drag_item(FIRST_ITEM, start=edited[0][FIRST_ITEM], end=QUIET) + index, _ = graph.item_under(release) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index] + + screen.press_shortcut(ShortcutId.UNDO) + + def the_drag_alone_is_undone(screen: Screen) -> None: + regeneration_hold.release() + + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, edited[0]) + screen.frames(SETTLE_FRAMES) + assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0] + + screen.scenario( + edit_the_sample_and_save, drag_and_undo_while_the_rebuild_is_held, the_drag_alone_is_undone + ).run() + + +class TestADragAndASaveAtOnce: + """A save asked for while a drag is on its way writes the drag.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_the_file_has_the_drag(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] + stored: List[bytes] = [] + + def drag_and_save_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + stored.append(SHORT_RECONSTRUCTION.read_bytes()) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + screen.press_shortcut(ShortcutId.SAVE_RECONSTRUCTION) + + screen.frames(SETTLE_FRAMES) + assert SHORT_RECONSTRUCTION.read_bytes() == stored[0] + + def the_save_writes_the_drag(screen: Screen) -> None: + regeneration_hold.release() + + index, expected = moved[0] + screen.expect( + lambda: stored_levels(SHORT_RECONSTRUCTION, ChannelName.PULSE1)[: index + 1], + expected[: index + 1].__eq__, + description="the file holding the drag", + ) + screen.frames(SETTLE_FRAMES) + assert screen.title() == titled(screen, SHORT_RECONSTRUCTION.name) + + screen.scenario(drag_and_save_while_the_rebuild_is_held, the_save_writes_the_drag).run() + + +class TestTwoDimensionsBeforeTheFadeEnds: + """A volume and an arpeggio dragged one after the other while the first is on its way both stay.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_both_edits_stay(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + instruments = screen.reconstructions.instruments + volume = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + arpeggio = instruments.graph(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + moved: List[Tuple[int, List[int]]] = [] + + def drag_the_volume_while_held(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(volume.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + + moved.append(dragged(volume, standing, end=QUIET, bounds=(0, LOUDEST))) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def drag_the_arpeggio_meanwhile(screen: Screen) -> None: + screen.hand.scroll_into_view(arpeggio.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + moved.append(dragged(arpeggio, standing, end=ARPEGGIO_STEP, bounds=(ARPEGGIO_FLOOR, ARPEGGIO_CEILING))) + + def both_stay(screen: Screen) -> None: + regeneration_hold.release() + + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, moved[0][1]) + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO, moved[1][1]) + assert screen.title() == titled(screen, marked(SHORT_RECONSTRUCTION.name, unsaved=True)) + + screen.scenario( + drag_the_volume_while_held, + drag_the_arpeggio_meanwhile, + both_stay, + leave_letting_it_go, + ).run() diff --git a/tests/screens/reconstructions/test_instruments.py b/tests/screens/reconstructions/test_instruments.py new file mode 100644 index 000000000..7b047f43a --- /dev/null +++ b/tests/screens/reconstructions/test_instruments.py @@ -0,0 +1,536 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Callable, Dict, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.project import ProjectContainer +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.keys import IMGUI_DIGIT_ZERO, IMGUI_LETTER_A +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import save_project_as +from tests.suite.screens.steps.reconstructions import edit_envelope, expect_open, marked, titled +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.views.waveform import read_cursor +from tests.suite.screens.world import OPEN_RECONSTRUCTION, PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT + +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" +ASIDE: Final[Point] = Point(x=0, y=0) +STILL_FRAMES: Final[int] = 20 +NOTE_FRAMES: Final[int] = 30 +FADING: Final[str] = "12 8 4" +LOOPING_VOLUME: Final[str] = "15 14 | 12 10" +LOOPING_DUTY: Final[str] = "0 1 | 2" +LOOP_FROM_THE_START: Final[str] = "| 15" +TWO_LOOPS: Final[str] = "15 | | 14" +NOT_A_NUMBER: Final[str] = "x" +TOO_LOUD: Final[str] = "99" +LOUDEST: Final[str] = "15" +NOTHING: Final[str] = "" +BENT_ITEM: Final[int] = 2 +FLAT_PITCH: Final[str] = "0 0 0 0 0" +BEND: Final[float] = 40.0 +PITCH_FLOOR: Final[int] = -128 +PITCH_CEILING: Final[int] = 127 +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") +PIANO_C_SHARP_UP: Final[int] = IMGUI_DIGIT_ZERO + 2 +PULSE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, + FeatureKey.DUTY_CYCLE, +) +TRIANGLE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, +) +NOISE_ROWS: Final[Tuple[FeatureKey, ...]] = (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) +PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.pitch_bend" +HI_PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.hi_pitch_bend" +TYPING_HINT: Final[str] = "reconstructions.instruments.message.status_sequence" +TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" +KEPT_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +INVALID_INPUT: Final[str] = "Invalid PULSE1 data input for VOLUME" +PLAY: Final[str] = "global.menu.label.item_playback_play" + + +def status_over(screen: Screen, item: str) -> str: + """Brings the pointer onto ``item`` from the window's corner, which is what sets the status line, and reads it.""" + screen.hand.move_to(ASIDE) + screen.hand.scroll_into_view(item) + screen.hand.hover(item) + return screen.status() + + +def saved_instrument(path: Path, name: str) -> Instrument: + """The instrument named ``name`` in the project stored at ``path``.""" + return next( + voice for voice in ProjectContainer.load(path).voices if isinstance(voice, Instrument) and voice.name == name + ) + + +def open_the_instrument(screen: Screen) -> None: + instruments = screen.reconstructions.instruments + open_voice(screen, SONG_INSTRUMENT) + + screen.expect(instruments.offers_audition, bool, description="the instrument open") + + +def song_title(screen: Screen, *, unsaved: bool) -> str: + return titled(screen, marked(SONG.stem, unsaved=unsaved)) + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, answering Exit to the question about the project.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def give_it_a_volume(screen: Screen) -> None: + open_the_instrument(screen) + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) + + +class TestTheRowsEachChannelDraws: + """Pulse and triangle tabs draw Pitch and Hi-pitch rows, the noise tab neither; a bend field explains a step.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_rows_per_channel_and_the_bend_explanation(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + expected = { + ChannelName.PULSE1: PULSE_ROWS, + ChannelName.PULSE2: PULSE_ROWS, + ChannelName.TRIANGLE: TRIANGLE_ROWS, + ChannelName.NOISE: NOISE_ROWS, + } + + def each_tab_draws_its_rows(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + for channel, rows in expected.items(): + instruments.bring_forward(channel) + screen.expect(partial(instruments.rows, channel), rows.__eq__, description=f"the rows of {channel}") + assert instruments.offers_pitch_stepper(channel) + + assert not instruments.offers_audition() + + def a_bend_field_explains_a_step(screen: Screen) -> None: + instruments.bring_forward(ChannelName.PULSE1) + for feature, key in ((FeatureKey.PITCH, PITCH_BEND), (FeatureKey.HI_PITCH, HI_PITCH_BEND)): + field = instruments.field(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + + screen.expect(partial(screen.shows_text, screen.words(key)), bool, description=f"{key} shown") + + def the_volume_field_explains_no_bend(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + screen.frames(STILL_FRAMES) + + assert not screen.shows_text(screen.words(PITCH_BEND)) + assert not screen.shows_text(screen.words(HI_PITCH_BEND)) + + screen.scenario(each_tab_draws_its_rows, a_bend_field_explains_a_step, the_volume_field_explains_no_bend).run() + + +class TestAHandWrittenVoice: + """A hand-written voice offers the Audition switch and no pitch stepper, and Export follows its envelopes.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_no_stepper_and_export_answers_while_it_sounds(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def its_tab_alone(screen: Screen) -> None: + open_the_instrument(screen) + + assert not instruments.offers_pitch_stepper(ChannelName.PULSE1) + assert [channel for channel in ChannelName if instruments.tab_shown(channel)] == [ChannelName.PULSE1] + assert not instruments.can_export(ChannelName.PULSE1) + + def a_volume_brings_export(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) + + screen.expect(lambda: instruments.can_export(ChannelName.PULSE1), bool, description="Export answering") + + def clearing_it_greys_export_out(screen: Screen) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, NOTHING) + + screen.expect( + lambda: instruments.can_export(ChannelName.PULSE1), operator.not_, description="Export greyed out" + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + screen.scenario( + its_tab_alone, + a_volume_brings_export, + clearing_it_greys_export_out, + leave_letting_the_project_go, + ).run() + + +class TestALoopTyped: + """A sequence typed with "|" repeats from the item after it, and a sibling of another length from its own point. + + The status line explains the "|" before anything is typed. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_each_envelope_keeps_its_own_loop(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def the_status_line_explains_the_bar(screen: Screen) -> None: + open_the_instrument(screen) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + + hint = screen.words(TYPING_HINT).format(instrument_feature=FeatureKey.VOLUME.capitalized) + screen.expect(partial(status_over, screen, field), hint.__eq__, description="the hint about the bar") + + def type_two_loops(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=LOOPING_VOLUME, + title=song_title(screen, unsaved=True), + ) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE, LOOPING_DUTY) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE), + LOOPING_DUTY.__eq__, + description="the duty cycle typed", + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOPING_VOLUME + + def the_project_keeps_each_loop(screen: Screen) -> None: + save_project_as(screen, SAVED_SONG) + + envelopes = saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes + assert (envelopes.volume.items, envelopes.volume.loop_point) == ((15, 14, 12, 10), 2) + assert (envelopes.duty_cycle.items, envelopes.duty_cycle.loop_point) == ((0, 1, 2), 2) + + screen.scenario(the_status_line_explains_the_bar, type_two_loops, the_project_keeps_each_loop).run() + + +class TestSequencesTheFieldAnswers: + """A sequence the field cannot read is refused and leaves the envelope; one it can read is applied as written. + + A value past the range is brought into it, an empty field clears the dimension, and one longer than + an export keeps is applied with a warning naming what the export keeps. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_refused_clamped_cleared_and_warned(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + long_sequence = " ".join(["8"] * (MAX_SEQUENCE_ITEMS + 1)) + plain: List[Optional[str]] = [] + + def typed(sequence: str) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, sequence) + screen.frames(STILL_FRAMES) + + def theme() -> Optional[str]: + return instruments.field_theme(ChannelName.PULSE1, FeatureKey.VOLUME) + + def unreadable_sequences_are_refused(screen: Screen) -> None: + open_the_instrument(screen) + plain.append(theme()) + + for sequence in (TWO_LOOPS, NOT_A_NUMBER): + typed(sequence) + + screen.claim_error(INVALID_INPUT) + assert theme() == TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == sequence + assert screen.title() == song_title(screen, unsaved=False) + + def a_loop_from_the_start_is_applied(screen: Screen) -> None: + typed(LOOP_FROM_THE_START) + + screen.expect(screen.title, song_title(screen, unsaved=True).__eq__, description="the edit applied") + assert theme() != TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOP_FROM_THE_START + + def a_value_past_the_range_is_brought_into_it(screen: Screen) -> None: + typed(TOO_LOUD) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + LOUDEST.__eq__, + description="the loudest level written", + ) + + def a_sequence_past_every_export_is_applied_with_a_warning(screen: Screen) -> None: + typed(long_sequence) + + screen.expect(theme, TAG_GLOBAL_THEME_INPUT_WARNING.__eq__, description="the warning") + instruments.bring_forward(ChannelName.PULSE1) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + warning = screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=MAX_SEQUENCE_ITEMS + 1, + kept=screen.words(KEPT_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + screen.expect( + partial(status_over, screen, field), warning.__eq__, description="the status naming what each keeps" + ) + + def an_empty_field_clears_it(screen: Screen) -> None: + typed(NOTHING) + + screen.expect(theme, plain[0].__eq__, description="the warning gone") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + def the_project_keeps_what_was_applied_last(screen: Screen) -> None: + typed(FADING) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="the last sequence applied", + ) + + save_project_as(screen, SAVED_SONG) + + assert saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes.volume.items == (12, 8, 4) + + screen.scenario( + unreadable_sequences_are_refused, + a_loop_from_the_start_is_applied, + a_value_past_the_range_is_brought_into_it, + a_sequence_past_every_export_is_applied_with_a_warning, + an_empty_field_clears_it, + the_project_keeps_what_was_applied_last, + ).run() + + +class TestDraggingAPitchBar: + """Dragging a bar of the Pitch row sets that item to the value the drag lets go at, and only that item.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_the_item_takes_the_value_under_the_release(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.PITCH) + standing: List[int] = [] + + def give_it_a_flat_pitch(screen: Screen) -> None: + give_it_a_volume(screen) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.PITCH, FLAT_PITCH) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH), + FLAT_PITCH.__eq__, + description="the flat pitch typed", + ) + + screen.hand.scroll_into_view(graph.plot) + + standing.extend(int(item) for item in FLAT_PITCH.split()) + + def drag_one_bar(screen: Screen) -> None: + release = graph.drag_item(BENT_ITEM, start=standing[BENT_ITEM], end=BEND) + + _, under = graph.value_under(release) + expected = [*standing] + expected[BENT_ITEM] = min(max(round(under), PITCH_FLOOR), PITCH_CEILING) + screen.expect( + lambda: [int(item) for item in instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH).split()], + expected.__eq__, + description="one item bent", + ) + assert expected[BENT_ITEM] != standing[BENT_ITEM] + + screen.scenario(give_it_a_flat_pitch, drag_one_bar, leave_letting_the_project_go).run() + + +class TestTheNoteKeys: + """With an instrument open the note keys sound it, a field being typed in takes them as characters, + and the keys a reconstruction gives a meaning keep it.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_keys_sound_the_instrument_and_a_field_keeps_them(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def sounded(key: int) -> bool: + with screen.record(partial(read_cursor, screen.reconstructions.waveform.cursor_line)) as recording: + screen.hand.press_key(key, modifiers=[]) + screen.frames(NOTE_FRAMES) + + return any(reading is not None and reading > 0 for reading in recording.values()) + + def the_keys_sound_it(screen: Screen) -> None: + give_it_a_volume(screen) + + assert sounded(PIANO_C) + assert sounded(PIANO_C_SHARP_UP) + + def a_field_takes_them_as_characters(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + screen.hand.scroll_into_view(field) + screen.hand.click(field) + + assert not sounded(PIANO_C) + assert "z" in instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + screen.scenario(the_keys_sound_it, a_field_takes_them_as_characters, leave_letting_the_project_go).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: an instrument's note keys take Ctrl+Z on the Reconstructions tab", + ) + def test_undo_undoes_with_an_instrument_open(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + NOTHING.__eq__, + description="the volume undone", + ) + + +class TestAChannelKeyOnAReconstruction: + """With a reconstruction open, a channel's number key ticks its box above the waveform.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_two_lets_pulse_two_go_and_brings_it_back(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + expect_open(screen, OPEN_RECONSTRUCTION) + assert reconstructions.channel_ticked(ChannelName.PULSE2) + + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + + screen.expect( + lambda: reconstructions.channel_ticked(ChannelName.PULSE2), operator.not_, description="Pulse 2 let go" + ) + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + screen.expect(lambda: reconstructions.channel_ticked(ChannelName.PULSE2), bool, description="Pulse 2 back") + + +class TestTheAuditionSwitch: + """The Audition switch draws the instrument on the generator it names, and back.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=SONG) + + def test_each_generator_redraws_the_waveform(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + waveform = screen.reconstructions.waveform + drawn: Dict[GeneratorName, List[float]] = {} + + def drawn_now() -> List[float]: + return waveform.drawn(SONG_INSTRUMENT) + + def on_the_pulse(screen: Screen) -> None: + give_it_a_volume(screen) + + assert instruments.audition() == screen.generator_words(GeneratorName.PULSE) + drawn[GeneratorName.PULSE] = drawn_now() + + def on_the_triangle(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.TRIANGLE)) + + drawn[GeneratorName.TRIANGLE] = screen.expect( + drawn_now, drawn[GeneratorName.PULSE].__ne__, description="the waveform redrawn" + ) + + def back_on_the_pulse(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.PULSE)) + + screen.expect(drawn_now, drawn[GeneratorName.PULSE].__eq__, description="the pulse drawn again") + + screen.scenario(on_the_pulse, on_the_triangle, back_on_the_pulse, leave_letting_the_project_go).run() + + +class TestAnInstrumentsWaveform: + """A click on an open instrument's waveform plays nothing, while a note key sounds it. + + A reconstruction that plays stands open before the instrument, so a click that sounds anything + at all is heard. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=SONG) + + def test_a_click_plays_nothing_and_a_key_does(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + + def cursors_over(gesture: Callable[[], None]) -> List[Optional[float]]: + with screen.record(partial(read_cursor, waveform.cursor_line)) as recording: + gesture() + screen.frames(NOTE_FRAMES) + + return recording.values() + + def a_click_plays_nothing(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + cursors = cursors_over(partial(waveform.click, 0.5)) + + assert not any(cursors) + assert screen.sequencer.playback.play_entry() == screen.words(PLAY) + + def a_note_key_sounds_it(screen: Screen) -> None: + cursors = cursors_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) + + assert any(cursors) + + screen.scenario(a_click_plays_nothing, a_note_key_sounds_it, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/test_player.py b/tests/screens/reconstructions/test_player.py new file mode 100644 index 000000000..34ac90ffa --- /dev/null +++ b/tests/screens/reconstructions/test_player.py @@ -0,0 +1,481 @@ +import operator +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.application import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser +from tests.suite.screens.views.waveform import Waveform +from tests.suite.screens.world import ( + PLAYABLE_RECONSTRUCTION, + SECOND_PLAYABLE, + TAKES, + World, + playing_world, +) + +SAMPLE_RATE: Final[int] = 44100 +PLAYING_WINDOW_SECONDS: Final[float] = 3.0 +STILL_FRAMES: Final[int] = 20 +ZOOM_NOTCHES: Final[int] = 3 +WIDTH_TOLERANCE: Final[float] = 0.01 +SPACE_BURST: Final[int] = 5 +PAST_THE_EDGE: Final[float] = 1.4 +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +SILENCE: Final[float] = 1e-6 +SILENT_SHARE_TOLERANCE: Final[float] = 0.05 +PLAY: Final[str] = "global.menu.label.item_playback_play" +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +RESUME: Final[str] = "global.menu.label.item_playback_resume" +ORIGINAL_SWITCH: Final[str] = "reconstructions.reconstruction.label.original_audio_radio" +RECONSTRUCTION_SWITCH: Final[str] = "reconstructions.reconstruction.label.reconstruction_radio" +ORIGINAL_LINE: Final[str] = "global.graph.label.waveform_original" +RECONSTRUCTION_LINE: Final[str] = "global.graph.label.waveform_reconstruction" + + +def entry(screen: Screen) -> str: + """What the Playback menu's first entry reads: Play while stopped, Pause while playing, Resume while paused.""" + return screen.sequencer.playback.play_entry() + + +def expect_entry(screen: Screen, key: str) -> None: + screen.expect(lambda: entry(screen), screen.words(key).__eq__, description=f"the Playback menu reading {key}") + + +def cursor(waveform: Waveform) -> float: + found = waveform.cursor() + assert found is not None, "no cursor stands on the waveform" + return found + + +def playing_from(screen: Screen, waveform: Waveform, sample: float) -> float: + """Waits for the cursor to stand at ``sample`` or a little past it, where playing from ``sample`` puts it.""" + window = PLAYING_WINDOW_SECONDS * SAMPLE_RATE + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: sample <= reading < sample + window, + description=f"the cursor playing on from sample {sample:.0f}", + ) + + +def advancing(screen: Screen, waveform: Waveform, after: float) -> float: + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: reading > after, + description=f"the cursor moving past sample {after:.0f}", + ) + + +def held_still(screen: Screen, waveform: Waveform) -> float: + """The cursor's sample, read twice some frames apart and found the same.""" + standing = cursor(waveform) + screen.frames(STILL_FRAMES) + assert waveform.cursor() == standing + return standing + + +def width(limits: Tuple[float, float]) -> float: + low, high = limits + return high - low + + +def silent_share(drawn: List[float]) -> float: + return sum(1 for height in drawn if abs(height) < SILENCE) / len(drawn) + + +class TestPlayingFromTheBrowser: + """A reconstruction opened from the browser plays on Space, its cursor moving along the waveform.""" + + def test_space_plays_and_the_cursor_moves_on(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + + def open_it(screen: Screen) -> None: + load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) + + expect_open(screen, PLAYABLE_RECONSTRUCTION) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def space_plays_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + + screen.scenario(open_it, space_plays_it).run() + + +class TestTheWaveformGestures: + """A click plays from a point, moves the cursor while paused and jumps while playing; Space pauses and resumes. + + A drag pans the view and a double-click fits it, and neither moves the cursor or changes what plays. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_clicks_and_space_steer_playback(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + stood: List[float] = [] + + def a_click_while_stopped_plays_from_there(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + + def space_pauses_and_the_cursor_holds(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + held_still(screen, waveform) + + def a_click_while_paused_moves_the_cursor_and_stays_paused(screen: Screen) -> None: + under = waveform.sample_under(0.25) + + waveform.click(0.25) + + screen.expect(waveform.cursor, float(round(under)).__eq__, description="the cursor where clicked") + stood.append(held_still(screen, waveform)) + assert entry(screen) == screen.words(RESUME) + + def space_resumes_from_the_cursor(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, stood[0]) + + def a_click_while_playing_jumps_there(screen: Screen) -> None: + under = waveform.sample_under(0.75) + + waveform.click(0.75) + + playing_from(screen, waveform, under) + assert entry(screen) == screen.words(PAUSE) + + def stop_takes_the_cursor_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + expect_entry(screen, PLAY) + screen.expect(waveform.cursor, operator.not_, description="no cursor") + + screen.scenario( + a_click_while_stopped_plays_from_there, + space_pauses_and_the_cursor_holds, + a_click_while_paused_moves_the_cursor_and_stays_paused, + space_resumes_from_the_cursor, + a_click_while_playing_jumps_there, + stop_takes_the_cursor_away, + ).run() + + def test_a_drag_pans_and_a_double_click_fits_without_touching_playback(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + fitted: List[Tuple[float, float]] = [] + stood: List[float] = [] + + def pause_partway(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + fitted.append( + screen.expect(waveform.limits, lambda limits: limits[1] > SAMPLE_RATE, description="the view fitted") + ) + waveform.click(0.5) + expect_entry(screen, PAUSE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + stood.append(held_still(screen, waveform)) + + def zoom_in_and_drag(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + zoomed = screen.expect( + waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed" + ) + + waveform.drag(0.6, 0.4) + + panned = screen.expect(waveform.limits, lambda limits: limits[0] > zoomed[0], description="panned") + assert abs(width(panned) - width(zoomed)) <= WIDTH_TOLERANCE * width(zoomed) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_fits_and_leaves_the_pause(screen: Screen) -> None: + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_leaves_playing_playing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + before = cursor(waveform) + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + assert entry(screen) == screen.words(PAUSE) + advancing(screen, waveform, before) + + def a_double_click_leaves_stopped_stopped(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + screen.scenario( + pause_partway, + zoom_in_and_drag, + a_double_click_fits_and_leaves_the_pause, + a_double_click_leaves_playing_playing, + a_double_click_leaves_stopped_stopped, + ).run() + + +class TestPressesThatAreNoClick: + """A triple click and a drag that leaves the plot play nothing, and the next plain click plays.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_triple_click_and_a_drag_off_the_plot_play_nothing(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + + def a_triple_click(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + waveform.triple_click(0.5) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_drag_off_the_plot(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + waveform.drag(0.5, PAST_THE_EDGE) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_plain_click_plays(screen: Screen) -> None: + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(a_triple_click, a_drag_off_the_plot, a_plain_click_plays).run() + + +class TestABurstOfSpace: + """Five presses of Space from a stop play, pause, resume, pause and resume: the burst ends playing.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_an_odd_burst_ends_playing(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + for _ in range(SPACE_BURST): + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + advancing(screen, waveform, cursor(waveform)) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + +class TestTheSourceSwitch: + """The switch above the waveform puts the source it names on top of the waveform, and back.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_source_chosen_is_drawn_on_top(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + original = waveform.series_tag(screen.words(ORIGINAL_LINE)) + reconstruction = waveform.series_tag(screen.words(RECONSTRUCTION_LINE)) + + def the_reconstruction_on_top(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + assert waveform.series() == (original, reconstruction) + + def the_original_chosen(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(ORIGINAL_SWITCH)) + + screen.expect(waveform.series, (reconstruction, original).__eq__, description="the original on top") + + def the_reconstruction_chosen_again(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(RECONSTRUCTION_SWITCH)) + + screen.expect(waveform.series, (original, reconstruction).__eq__, description="the reconstruction on top") + + screen.scenario(the_reconstruction_on_top, the_original_chosen, the_reconstruction_chosen_again).run() + + +class TestTheSourceSwitchWithItsRecordingGone: + """A reconstruction whose recording is gone offers the reconstruction alone.""" + + @pytest.fixture + def world(self) -> World: + world = playing_world() + gone = Path.cwd() / TAKES[0] + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(file for file in world.files if not (isinstance(file, Recording) and file.destination == gone)), + ) + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_switch_stays_on_the_reconstruction(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + notice = screen.file_not_found_notice + + def the_missing_recording_is_named(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + + assert str(Path.cwd() / TAKES[0]) in notice.words() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + def the_switch_offers_nothing_else(screen: Screen) -> None: + assert not reconstructions.can_choose_audio_source() + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + + screen.scenario(the_missing_recording_is_named, the_switch_offers_nothing_else).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a reconstruction whose recording is missing draws a flat original line", + ) + def test_the_waveform_draws_the_reconstruction_alone(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + notice = screen.file_not_found_notice + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + assert waveform.series() == (waveform.series_tag(screen.words(RECONSTRUCTION_LINE)),) + + +class TestAnotherReconstructionAfterClosing: + """A reconstruction opened after another was closed draws its cursor as it plays.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_its_cursor_moves_on(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + + def play_and_close_the_first(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + reconstructions.close_from_menu() + + screen.expect(reconstructions.file_line, operator.not_, description="nothing open") + + def open_the_second_and_play(screen: Screen) -> None: + load_from_the_browser(screen, SECOND_PLAYABLE) + expect_open(screen, SECOND_PLAYABLE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(play_and_close_the_first, open_the_second_and_play).run() + + +class TestChoosingChannels: + """The waveform draws the channels ticked above it, and ticking one back draws what it drew before.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_beat_alone_falls_silent_between_its_strokes(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + line = screen.words(RECONSTRUCTION_LINE) + drawn: Dict[str, List[float]] = {} + + def every_channel_sounds_throughout(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + drawn["all"] = waveform.drawn(line) + + assert silent_share(drawn["all"]) == 0.0 + + def the_beat_alone(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + beat = screen.expect( + lambda: waveform.drawn(line), + drawn["all"].__ne__, + description="the waveform redrawn", + ) + assert abs(silent_share(beat) - (BEAT_FRAMES - BEAT_LENGTH) / BEAT_FRAMES) <= SILENT_SHARE_TOLERANCE + + def every_channel_back(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + screen.expect(lambda: waveform.drawn(line), drawn["all"].__eq__, description="the waveform as before") + + screen.scenario(every_channel_sounds_throughout, the_beat_alone, every_channel_back).run() diff --git a/tests/screens/reconstructions/test_stems.py b/tests/screens/reconstructions/test_stems.py new file mode 100644 index 000000000..80c4ba082 --- /dev/null +++ b/tests/screens/reconstructions/test_stems.py @@ -0,0 +1,268 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser, marked, titled +from tests.suite.screens.views.stems import StemsCard +from tests.suite.screens.world import ( + SHORT_RECONSTRUCTION, + STEM_TAKES, + STEMS_RECONSTRUCTION, +) + +KEYS: Final[Tuple[str, ...]] = ("0", "1", "2") +FIRST: Final[str] = "0" +MIDDLE: Final[str] = "1" +LAST: Final[str] = "2" +STILL_FRAMES: Final[int] = 20 +SILENT: Final[float] = 0.4 +PLAYING_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +REMOVE_TITLE: Final[str] = "reconstructions.reconstruction.title.remove_stem_dialog" +REMOVE_MESSAGE: Final[str] = "reconstructions.reconstruction.message.remove_stem_message" +NOTHING_EXPORTED: Final[str] = "global.context.template.size_bytes" +PAUSE: Final[str] = "global.menu.label.item_playback_pause" + + +def take(index: int) -> Path: + return Path.cwd() / STEM_TAKES[index] + + +def swatches(stems: StemsCard, keys: Tuple[str, ...]) -> Dict[str, Tuple[float, ...]]: + return {key: stems.swatch(key) for key in keys} + + +def stems_title(screen: Screen, *, unsaved: bool) -> str: + return titled(screen, marked(STEMS_RECONSTRUCTION.name, unsaved=unsaved)) + + +class TestRevealingARecording: + """A double-click on a row shows its recording in the file manager; a click shows nothing; no row stays lit.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_a_double_click_shows_the_recording_and_leaves_the_row_plain(self, screen: Screen) -> None: + stems = screen.reconstructions.stems + + def a_click_shows_nothing(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + + stems.click(FIRST) + + screen.frames(STILL_FRAMES) + assert screen.revealed() == () + assert not any(stems.is_highlighted(key) for key in KEYS) + + def a_double_click_shows_that_recording(screen: Screen) -> None: + stems.reveal(FIRST) + + screen.expect(screen.revealed, (take(0),).__eq__, description="the first recording shown") + assert not any(stems.is_highlighted(key) for key in KEYS) + + def another_row_shows_its_own(screen: Screen) -> None: + stems.reveal(LAST) + + screen.expect(screen.revealed, (take(0), take(2)).__eq__, description="the last recording shown") + assert not any(stems.is_highlighted(key) for key in KEYS) + + screen.scenario(a_click_shows_nothing, a_double_click_shows_that_recording, another_row_shows_its_own).run() + + +class TestRemovingARecording: + """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_cancel_keeps_it_and_remove_leaves_the_rest_as_they_were(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + stems = reconstructions.stems + prompt = stems.remove_prompt + colors: List[Dict[str, Tuple[float, ...]]] = [] + + def the_file_named(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + + assert screen.title() == stems_title(screen, unsaved=False) + assert all(stems.has_row(key) for key in KEYS) + colors.append(swatches(stems, KEYS)) + + def cancel_keeps_it(screen: Screen) -> None: + stems.remove(MIDDLE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + assert prompt.title() == screen.words(REMOVE_TITLE) + assert screen.words(REMOVE_MESSAGE) in prompt.words() + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(STILL_FRAMES) + assert all(stems.has_row(key) for key in KEYS) + assert screen.title() == stems_title(screen, unsaved=False) + + def remove_the_middle(screen: Screen) -> None: + stems.remove(MIDDLE) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(lambda: stems.has_row(MIDDLE), operator.not_, description="the middle recording gone") + assert screen.title() == stems_title(screen, unsaved=True) + assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + + def the_rest_plays(screen: Screen) -> None: + waveform = reconstructions.waveform + + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + screen.expect(lambda: waveform.cursor() or 0.0, lambda reading: reading > first, description="moving on") + screen.press_shortcut(ShortcutId.STOP) + + def the_last_cannot_go(screen: Screen) -> None: + stems.remove(FIRST) + screen.expect(prompt.is_shown, bool, description="the question about the first") + prompt.confirm() + screen.expect(lambda: stems.has_row(FIRST), operator.not_, description="the first recording gone") + + assert not stems.can_remove(LAST) + assert stems.swatch(LAST) == colors[0][LAST] + + def saved_and_opened_again_it_keeps_its_color(screen: Screen) -> None: + reconstructions.save_from_menu() + screen.expect(screen.title, stems_title(screen, unsaved=False).__eq__, description="saved") + reconstructions.close_from_menu() + screen.expect(reconstructions.file_line, operator.not_, description="closed") + + load_from_the_browser(screen, STEMS_RECONSTRUCTION) + + expect_open(screen, STEMS_RECONSTRUCTION) + screen.expect(lambda: stems.has_row(LAST), bool, description="the last recording listed") + assert not stems.has_row(FIRST) + assert not stems.has_row(MIDDLE) + assert stems.swatch(LAST) == colors[0][LAST] + + screen.scenario( + the_file_named, + cancel_keeps_it, + remove_the_middle, + the_rest_plays, + the_last_cannot_go, + saved_and_opened_again_it_keeps_its_color, + ).run() + + +class TestSilencingARecording: + """A recording whose every frame falls silent leaves the card, and the others keep their colors. + + With the middle recording heard alone, each channel's volume is dragged to silence across every + bar, which leaves the channel standing by for that recording. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_its_row_leaves_and_the_others_keep_their_colors(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + stems = reconstructions.stems + instruments = reconstructions.instruments + colors: List[Dict[str, Tuple[float, ...]]] = [] + + def hear_the_middle_alone(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + colors.append(swatches(stems, KEYS)) + + for key in (FIRST, LAST): + stems.tick(key) + screen.expect(partial(stems.hears, key), operator.not_, description=f"{key} left unheard") + + def silence_every_channel_of_it(screen: Screen) -> None: + for channel in PLAYING_CHANNELS: + instruments.bring_forward(channel) + graph = instruments.graph(channel, FeatureKey.VOLUME) + screen.hand.scroll_into_view(graph.plot) + + bars = len(instruments.envelope(channel, FeatureKey.VOLUME).split()) + + graph.drag_across(0, bars - 1, value=SILENT) + + screen.expect( + partial(instruments.envelope, channel, FeatureKey.VOLUME), + operator.not_, + description=f"{channel} standing by", + ) + + def its_row_leaves(screen: Screen) -> None: + screen.expect(lambda: stems.has_row(MIDDLE), operator.not_, description="the middle recording gone") + + assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + + def leave_letting_it_go(screen: Screen) -> None: + prompt = reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(hear_the_middle_alone, silence_every_channel_of_it, its_row_leaves, leave_letting_it_go).run() + + +class TestLettingAChannelGo: + """A channel whose volume is dragged to silence on every frame mutes its tab and exports nothing. + + The silence is dragged at a height that rounds to it, above the strip under the bars that a + press ignores. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_the_tab_mutes_and_its_size_reads_nothing(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + + def drag_pulse_one_to_silence(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + assert instruments.tab_theme(ChannelName.PULSE1) != TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED + + bars = len(instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME).split()) + + graph.drag_across(0, bars - 1, value=SILENT) + + screen.expect( + lambda: instruments.tab_theme(ChannelName.PULSE1), + TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED.__eq__, + description="the tab muted", + ) + assert instruments.size(ChannelName.PULSE1) == screen.words(NOTHING_EXPORTED).format(bytes=0) + assert not instruments.can_export(ChannelName.PULSE1) + + def leave_letting_it_go(screen: Screen) -> None: + prompt = reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(drag_pulse_one_to_silence, leave_letting_it_go).run() diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application.py index f5d7be077..4ab1a7d74 100644 --- a/tests/suite/screens/application.py +++ b/tests/suite/screens/application.py @@ -12,6 +12,7 @@ from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.reveals import FileManagerStandIn from tests.suite.screens.boundaries.spawns import SpawnGuard from tests.suite.screens.checks import ( AfterCheckError, @@ -58,6 +59,7 @@ class Boundaries: errors: ErrorRecords spawns: SpawnGuard holds: Holds + file_manager: FileManagerStandIn class ScreenApplication: diff --git a/tests/suite/screens/boundaries/reveals.py b/tests/suite/screens/boundaries/reveals.py new file mode 100644 index 000000000..5a6f60ae1 --- /dev/null +++ b/tests/suite/screens/boundaries/reveals.py @@ -0,0 +1,55 @@ +import os +import stat +from pathlib import Path +from typing import Final, Mapping, Tuple + +import pytest + +PATH_VARIABLE: Final[str] = "PATH" +FILE_MANAGER: Final[str] = "nemo" +FILE_MANAGER_ENTRY: Final[str] = f"{FILE_MANAGER}.desktop" +EXECUTABLE: Final[int] = stat.S_IRWXU + + +class FileManagerStandIn: + """Stands in for the desktop's file manager, noting every path the application asks it to show. + + The application asks ``xdg-mime`` which file manager the desktop uses and then asks that file + manager, or ``xdg-open``, to show a path. Programs of those names lie in ``folder``, which leads + the search path, so the application's own reveal code runs and reaches them: the first names a + file manager they stand for, and the others write the path they were given to ``log``, one line + each. + """ + + def __init__( + self, + folder: Path, + log: Path, + ) -> None: + self.folder = folder + self._log = log + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Lays the programs in the folder and puts the folder first on the search path.""" + self.folder.mkdir(parents=True, exist_ok=True) + for name, script in self._programs().items(): + program = self.folder / name + program.write_text(script) + program.chmod(EXECUTABLE) + + monkeypatch.setenv(PATH_VARIABLE, os.pathsep.join((str(self.folder), os.environ[PATH_VARIABLE]))) + + def shown(self) -> Tuple[Path, ...]: + """Every path the file manager was asked to show, in order.""" + if not self._log.exists(): + return () + + return tuple(Path(line) for line in self._log.read_text().splitlines()) + + def _programs(self) -> Mapping[str, str]: + noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._log}"; done\n' + return { + "xdg-mime": f"#!/bin/sh\necho {FILE_MANAGER_ENTRY}\n", + FILE_MANAGER: noting, + "xdg-open": noting, + } diff --git a/tests/suite/screens/boundaries/spawns.py b/tests/suite/screens/boundaries/spawns.py index 823e0be90..6eff218f1 100644 --- a/tests/suite/screens/boundaries/spawns.py +++ b/tests/suite/screens/boundaries/spawns.py @@ -1,7 +1,8 @@ +import shutil import sys import threading from pathlib import Path -from typing import Any, Final, FrozenSet, List, Tuple +from typing import Any, Final, FrozenSet, List, Optional, Tuple LIBRARY_PROBES: Final[FrozenSet[str]] = frozenset({"cc", "gcc", "ld", "ldconfig", "objdump"}) PROGRAM_ARGUMENT: Final[int] = 0 @@ -30,19 +31,25 @@ class SpawnGuard: The guard is an audit hook, so it stands in front of every way Python starts a program, however a module imported it. The application's own worker processes start through multiprocessing, which the hook leaves alone, and so are the tools ``ctypes.util.find_library`` asks where a - shared library lies. A refused try is remembered by its program and arguments alone, since the - environment it carried can hold the secrets of the machine the run is on. + shared library lies, and the stand-in programs of the folder the guard is told to allow. A + refused try is remembered by its program and arguments alone, since the environment it carried + can hold the secrets of the machine the run is on. """ def __init__(self) -> None: self._refused: List[str] = [] self._armed = False + self._allowed: Optional[Path] = None self._lock = threading.Lock() def install(self) -> None: """Puts the guard in front of the process for good; it refuses nothing until armed.""" sys.addaudithook(self._audit) + def allow(self, folder: Path) -> None: + """Lets the programs lying in ``folder`` start, which is where the scenario's stand-ins lie.""" + self._allowed = folder + def arm(self) -> None: self._armed = True @@ -60,7 +67,7 @@ def _audit( return program = Path(str(arguments[PROGRAM_ARGUMENT])).name if arguments else "" - if program in LIBRARY_PROBES: + if program in LIBRARY_PROBES or self._is_allowed(arguments): return attempt = f"{event} {program} {_described(arguments)}" @@ -69,6 +76,14 @@ def _audit( raise SpawnRefusedError(f"A screen scenario refuses to start a program: {attempt}") + def _is_allowed(self, arguments: Tuple[Any, ...]) -> bool: + """Whether the program a try names lies in the folder of stand-ins the guard allows.""" + if self._allowed is None or not arguments: + return False + + located = shutil.which(str(arguments[PROGRAM_ARGUMENT])) + return located is not None and Path(located).parent == self._allowed + def _described(arguments: Tuple[Any, ...]) -> str: if len(arguments) > ARGUMENTS_ARGUMENT: diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index ca981fb6b..4ae6644d4 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -12,12 +12,14 @@ enclosing_regions, read_hovered, read_item, + read_pointer, read_region_view, read_scroll, read_viewport, read_visible_box, ) from tests.suite.screens.dearpygui.keys import ( + IMGUI_BACKSPACE, IMGUI_LEFT_CTRL, IMGUI_LEFT_SHIFT, IMGUI_LETTER_A, @@ -34,7 +36,9 @@ SETTLE_FRAMES: Final[int] = 2 SINGLE_PRESS: Final[int] = 1 DOUBLE_PRESS: Final[int] = 2 +TRIPLE_PRESS: Final[int] = 3 HOVER_ARRIVAL_FRAMES: Final[int] = 5 +ARRIVAL_FRAMES: Final[int] = 10 REACH_TIMEOUT_SECONDS: Final[float] = 10.0 WHEEL_FRAMES: Final[int] = 1 SCROLL_NOTCH_LIMIT: Final[int] = 100 @@ -138,13 +142,30 @@ def click_at(self, point: Point) -> None: The press confirms it reached the application, while what it landed on is for the scenario to read from what the press changed. """ - viewport = self._bridge.ask(read_viewport) - self._device.move(Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y)) + self._press_at(point, SINGLE_PRESS) + + def double_click_at(self, point: Point) -> None: + """Presses the left button twice in quick succession at ``point`` of the viewport.""" + self._press_at(point, DOUBLE_PRESS) + + def triple_click_at(self, point: Point) -> None: + """Presses the left button three times in quick succession at ``point`` of the viewport.""" + self._press_at(point, TRIPLE_PRESS) + + def move_to(self, point: Point) -> None: + """Rests the pointer at ``point`` of the viewport.""" + self._device.move(self._on_display(point)) self._settle(HOVER_FRAMES) - self._device.button_down(MouseButton.LEFT) - self._settle(HOLD_FRAMES) - self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") - self._device.button_up(MouseButton.LEFT) + + def drag(self, start: Point, end: Point) -> None: + """Presses the left button at ``start`` of the viewport, carries it to ``end`` over a few frames, and lets go. + + The press is confirmed held as the pointer sets off, so a drag the application never saw + fails where it was lost. + """ + self._outlast_a_double_click() + self._drag_between(self._on_display(start), self._on_display(end)) + self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) def hover(self, item: Item) -> None: @@ -175,6 +196,16 @@ def wheel( self._turn_wheel(notches) self._settle(SETTLE_FRAMES) + def wheel_at( + self, + point: Point, + notches: int, + ) -> None: + """Rests the pointer at viewport point ``point`` and turns the wheel ``notches`` notches, down if positive.""" + self.move_to(point) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + def turn_wheel_over( self, region: Item, @@ -294,9 +325,10 @@ def replace_text( item: Item, text: str, ) -> None: - """Clicks the field ``item``, selects what it holds, and types ``text`` over it.""" + """Clicks the field ``item``, selects what it holds, deletes it, and types ``text`` in its place.""" self.click(item) self.press_key(IMGUI_LETTER_A, modifiers=[IMGUI_LEFT_CTRL]) + self.press_key(IMGUI_BACKSPACE, modifiers=[]) self.type_text(text) def type_text(self, text: str) -> None: @@ -361,21 +393,44 @@ def _application_seconds(self) -> Optional[float]: except RenderThreadStoppedError: return None + def _press_at(self, point: Point, count: int) -> None: + self.move_to(point) + self._outlast_a_double_click() + for _ in range(count): + self._press_once(MouseButton.LEFT, HOLD_FRAMES) + + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _on_display(self, point: Point) -> Point: + """The display pixel at ``point`` of the viewport.""" + viewport = self._bridge.ask(read_viewport) + return Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y) + def _drag_between(self, start: Point, end: Point) -> None: - """Presses the left button at ``start``, carries it to ``end`` over a few frames, and lets go there.""" + """Presses the left button at display pixel ``start``, carries it to ``end`` over a few frames, and lets go. + + Each step waits until the application reads the pointer where the step put it, so every + point the drag passes through reaches the application while the button is down, and the + pointer rests at ``end`` a few frames before the button comes up, as a person stops before + letting go. + """ self._device.move(start) self._settle(HOVER_FRAMES) self._device.button_down(MouseButton.LEFT) self._settle(HOLD_FRAMES) + self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") + for step in range(1, DRAG_STEPS + 1): - self._device.move( - Point( - x=start.x + round((end.x - start.x) * step / DRAG_STEPS), - y=start.y + round((end.y - start.y) * step / DRAG_STEPS), - ) + point = Point( + x=start.x + round((end.x - start.x) * step / DRAG_STEPS), + y=start.y + round((end.y - start.y) * step / DRAG_STEPS), ) + self._device.move(point) self._settle(ONE_FRAME) + self._confirm_pointer(point) + self._settle(HOLD_FRAMES) self._device.button_up(MouseButton.LEFT) self._settle(RELEASE_FRAMES) @@ -407,6 +462,25 @@ def _hovered_within(self, item: Item, frames: int) -> bool: return False + def _confirm_pointer(self, point: Point) -> None: + """Waits a few frames for the application to read the pointer at display pixel ``point``. + + Raises: + GestureLostError: If the application reads the pointer elsewhere throughout. + """ + viewport = self._bridge.ask(read_viewport) + expected = Point(x=point.x - round(viewport.x), y=point.y - round(viewport.y)) + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(read_pointer) == expected: + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"The pointer was moved to {expected} and the application reads it elsewhere") + def _confirm_keys(self, keys: Sequence[int]) -> None: for key in keys: self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") @@ -418,16 +492,22 @@ def _confirm( ) -> None: """Checks that the application reads a held button or key as down, unless the gesture closed it. + A display busy with other work hands the press on a few frames late, so the check reads it + once a frame for a few frames while it stays held. + Raises: - GestureLostError: If the application reads it as up while it is held. + GestureLostError: If the application reads it as up throughout. """ - try: - arrived = self._bridge.ask(is_down) - except RenderThreadStoppedError: - return + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(is_down): + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) - if not arrived: - raise GestureLostError(f"{what} is held on the display and the application reads it as up") + raise GestureLostError(f"{what} is held on the display and the application reads it as up") def _settle(self, count: int) -> None: """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py index 450a53c44..b8b6b26b0 100644 --- a/tests/suite/screens/dearpygui/items.py +++ b/tests/suite/screens/dearpygui/items.py @@ -291,6 +291,29 @@ def read_texts(container: Item) -> Tuple[str, ...]: return _texts(container, shown_only=False) +def read_theme_colors(theme: Item) -> Tuple[Tuple[float, ...], ...]: + """The colors every theme color under ``theme`` holds, in the order they were added. Runs on the render thread.""" + colors: List[Tuple[float, ...]] = [] + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + colors.append(tuple(float(part) for part in dpg.get_value(color))) + + return tuple(colors) + + +def read_visible_text(words: str) -> bool: + """Whether a text item reading ``words`` was drawn in the last frame, such as a tooltip standing open. + + Runs on the render thread. + """ + return any( + dpg.get_item_info(item)["type"] == TEXT_TYPE + and dpg.get_value(item) == words + and bool(dpg.is_item_visible(item)) + for item in dpg.get_all_items() + ) + + def read_shown_labels(container: Item, item_type: str) -> Tuple[str, ...]: """The labels of every shown item of ``item_type`` under ``container``, in the order they are drawn. diff --git a/tests/suite/screens/holds.py b/tests/suite/screens/holds.py index 213dc4882..47333d081 100644 --- a/tests/suite/screens/holds.py +++ b/tests/suite/screens/holds.py @@ -172,9 +172,9 @@ class RegenerationHold: The rebuild waits where the real one runs, so the application draws and answers while the edit is on its way, and once released the real rebuild computes the edit's result. The hold lets - every rebuild through from its release on. A scenario standing for a rebuild that breaks lets - the held rebuilds go as that failure instead, which the service reports the way it reports a - rebuild that raised. + every rebuild through from its release on, until the scenario holds again. A scenario standing + for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service + reports the way it reports a rebuild that raised. """ def __init__(self) -> None: @@ -210,6 +210,10 @@ def waiting(self) -> int: def release(self) -> None: self._released.set() + def hold_again(self) -> None: + """Holds every rebuild asked for from now on, as the hold did before its release.""" + self._released.clear() + def fail(self, failure: Exception) -> None: """Lets every held rebuild, and every one asked for after, go as ``failure``.""" self._failure = failure diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index 126d6831b..9e61489f3 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -10,6 +10,7 @@ from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.reveals import FileManagerStandIn from tests.suite.screens.boundaries.spawns import SpawnGuard from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.display import VirtualDisplay @@ -39,6 +40,8 @@ CONVERSION_RELEASE_FILE: Final[str] = "release-conversion" SCAN_RELEASE_FILE: Final[str] = "release-scan" SCAN_ENTRY_SECONDS: Final[float] = 0.05 +STAND_IN_PROGRAMS: Final[str] = "programs" +REVEALS_FILE: Final[str] = "reveals.log" SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() @@ -211,8 +214,12 @@ def screen_boundaries( dialogs.install(monkeypatch) errors = ErrorRecords() errors.install() + artifacts = Path(os.environ[ARTIFACTS_VARIABLE]) + file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE) + file_manager.install(monkeypatch) spawns = SpawnGuard() spawns.install() + spawns.allow(file_manager.folder) provide_output_device( output_device, home=Path.home(), @@ -223,6 +230,7 @@ def screen_boundaries( errors=errors, spawns=spawns, holds=screen_holds, + file_manager=file_manager, ) @@ -271,6 +279,7 @@ def screen( shortcuts=screen_application.shortcuts, dialogs=screen_boundaries.dialogs, errors=screen_boundaries.errors, + file_manager=screen_boundaries.file_manager, artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), ) window_manager.close() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 40d94896c..5afb0175d 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -2,19 +2,25 @@ from pathlib import Path from typing import Callable, Final, Iterator, Optional, Tuple, TypeVar, Union +from sampletones_application.categories.context import generator_label from sampletones_application.categories.key.text import TextKeyTuple from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_BUTTON, TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, + TAG_GLOBAL_STATUS_BAR, TAG_GLOBAL_WINDOW_MAIN, ) from sampletones_application.tags.main import TAG_MAIN_EXPLORER_TREE from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager +from sampletones_core.constants.enums import GeneratorName from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.reveals import FileManagerStandIn from tests.suite.screens.dearpygui.bridge import Bridge, ExpectationError from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand @@ -22,9 +28,11 @@ Item, WindowReading, read_item_count, + read_label, read_pointer, read_theme, read_viewport_title, + read_visible_text, read_windows, ) from tests.suite.screens.dearpygui.recording import FrameRecording @@ -50,6 +58,7 @@ EXPECT_TIMEOUT_SECONDS: Final[float] = 10.0 STEP_SEPARATOR: Final[str] = " โ†’ " SCREENSHOT_SUFFIX: Final[str] = ".png" +STATUS_BAR: Final[str] = compose_tag(TAG_GLOBAL_STATUS_BAR, SUF_BUTTON) class Screen: @@ -72,6 +81,7 @@ def __init__( shortcuts: ShortcutManager, dialogs: ScriptedFileDialogs, errors: ErrorRecords, + file_manager: FileManagerStandIn, artifacts: Path, ) -> None: self.bridge = bridge @@ -81,6 +91,7 @@ def __init__( self._shortcuts = shortcuts self._dialogs = dialogs self._errors = errors + self._file_manager = file_manager self._artifacts = artifacts self._language = language self.tabs = Tabs(bridge, hand) @@ -116,6 +127,18 @@ def words(self, key: Union[str, TextKeyTuple]) -> str: """What the language file says under ``key``, which is what the application shows the user.""" return self._language[key] + def generator_words(self, generator: GeneratorName) -> str: + """The words the application names ``generator`` by wherever it offers one.""" + return generator_label(self._language, generator) + + def status(self) -> str: + """What the status bar along the bottom of the window says.""" + return self.bridge.ask(lambda: read_label(STATUS_BAR)) + + def shows_text(self, words: str) -> bool: + """Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open.""" + return self.bridge.ask(lambda: read_visible_text(words)) + def expect_item( self, reading: Callable[[], Optional[Item]], @@ -186,6 +209,10 @@ def dialog_requests(self) -> Tuple[DialogRequest, ...]: """Every file dialog the application opened so far, in order.""" return self._dialogs.requests + def revealed(self) -> Tuple[Path, ...]: + """Every path the application asked the desktop's file manager to show, in order.""" + return self._file_manager.shown() + def shown_windows(self) -> Tuple[WindowReading, ...]: """Every window standing on the screen besides the application's main one.""" return tuple( diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py index 6bb7ea315..db8785dea 100644 --- a/tests/suite/screens/seeds.py +++ b/tests/suite/screens/seeds.py @@ -2,14 +2,16 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from typing import Final +from typing import Dict, Final, List, Tuple import numpy as np import soundfile from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Window +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.project import ProjectContainer from sampletones_core.project.project import Project @@ -18,6 +20,7 @@ from sampletones_core.reconstructions import Reconstruction from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document from tests.suite.library import build_served_library +from tests.suite.stems import recorded_from, single_entry_stems_data FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" TRUNCATED_FRACTION: Final[int] = 2 @@ -27,6 +30,16 @@ STORED_RECORDING_SECONDS: Final[float] = 0.5 STORED_RECORDING_FREQUENCY: Final[float] = 220.0 AUDIO_PATH_FIELD: Final[str] = "audio_filepath" +LINE_START_PITCH: Final[int] = 60 +LINE_STEP_FRAMES: Final[int] = 15 +LINE_SPAN: Final[int] = 12 +LINE_VOLUME: Final[int] = 12 +LINE_DUTY_CYCLE: Final[int] = 2 +BASS_PITCH: Final[int] = 45 +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +BEAT_PERIOD: Final[int] = 4 +BEAT_VOLUME: Final[int] = 10 class Damage(StrEnum): @@ -121,6 +134,58 @@ def write(self) -> None: ProjectContainer.save(project, self.destination) +@dataclass(frozen=True) +class PlayableReconstruction: + """A reconstruction sounding a rising line on Pulse 1, a bass on the triangle and a beat on the noise. + + Each recording of ``recordings`` plays the ``frames`` frames once, in turn, so the document + lasts as long as the recordings laid at those paths, one :class:`Recording` of ``frames`` frames + each, and opens with its sources. + """ + + destination: Path + recordings: Tuple[Path, ...] + frames: int + + def write(self) -> None: + instructions = _playable_instructions(self.frames) + reconstruction = Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=self.recordings[:1], + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + if len(self.recordings) > 1: + reconstruction = recorded_from(reconstruction, self.recordings) + + self.destination.parent.mkdir(parents=True, exist_ok=True) + reconstruction.save(self.destination) + + +def _playable_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]: + line: List[InstructionUnion] = [ + PulseInstruction( + on=True, + pitch=LINE_START_PITCH + (frame // LINE_STEP_FRAMES) % LINE_SPAN, + volume=LINE_VOLUME, + duty_cycle=LINE_DUTY_CYCLE, + ) + for frame in range(frames) + ] + bass: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH) for _ in range(frames)] + beat: List[InstructionUnion] = [ + NoiseInstruction( + on=frame % BEAT_FRAMES < BEAT_LENGTH, + period=BEAT_PERIOD, + volume=BEAT_VOLUME if frame % BEAT_FRAMES < BEAT_LENGTH else 0, + short=False, + ) + for frame in range(frames) + ] + return {ChannelName.PULSE1: line, ChannelName.TRIANGLE: bass, ChannelName.NOISE: beat} + + def stored_recording() -> Recording: """The recording the archived reconstruction names, laid where its relative path leads from the home. diff --git a/tests/suite/screens/steps/instructions.py b/tests/suite/screens/steps/instructions.py new file mode 100644 index 000000000..9badf784d --- /dev/null +++ b/tests/suite/screens/steps/instructions.py @@ -0,0 +1,41 @@ +import operator +from functools import partial +from pathlib import Path + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.screen import Screen + + +def library_path(config: Config) -> Path: + """Where the library built for ``config`` is stored, under the name its settings give it.""" + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +def library_row(screen: Screen, path: Path) -> Item: + """The library card's row of the library stored at ``path``, once it is drawn.""" + library = screen.instructions.library + return screen.expect_item(partial(library.row, path.name), description=f"the row of {path.name}") + + +def open_library_row(screen: Screen, row: Item) -> None: + """Opens a library's row, which is the click that loads it, closing it first where it stands open.""" + tree = screen.instructions.library.tree + if tree.is_open(row): + tree.open_by_click(row) + screen.expect(partial(tree.is_open, row), operator.not_, description="the row closed") + + tree.open_by_click(row) + + +def load_library(screen: Screen, config: Config) -> Item: + """Brings the Instructions tab forward and opens the row of the library built for ``config``, which loads it.""" + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + row = library_row(screen, library_path(config)) + open_library_row(screen, row) + return row diff --git a/tests/suite/screens/steps/reconstructions.py b/tests/suite/screens/steps/reconstructions.py index f4a1f8b33..9cc2d79aa 100644 --- a/tests/suite/screens/steps/reconstructions.py +++ b/tests/suite/screens/steps/reconstructions.py @@ -74,7 +74,7 @@ def edit_envelope( ) -> None: """Types ``sequence`` over a channel's envelope and waits for the edit to land, which marks the title ``title``.""" screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - screen.reconstructions.type_envelope(channel, feature, sequence) + screen.reconstructions.instruments.type_envelope(channel, feature, sequence) screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") @@ -144,7 +144,7 @@ def raise_the_first_level( screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) sequence = first_raised( screen.expect( - lambda: screen.reconstructions.envelope(channel, FeatureKey.VOLUME), + lambda: screen.reconstructions.instruments.envelope(channel, FeatureKey.VOLUME), bool, description=f"the volume of {channel} drawn", ) @@ -173,9 +173,9 @@ def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, cha str: The sequence typed. """ reconstructions = screen.reconstructions - sequence = first_raised(reconstructions.envelope(channel, FeatureKey.VOLUME)) + sequence = first_raised(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME)) - reconstructions.type_envelope(channel, FeatureKey.VOLUME, sequence) + reconstructions.instruments.type_envelope(channel, FeatureKey.VOLUME, sequence) screen.expect(hold.waiting, bool, description="the rebuild held") return sequence diff --git a/tests/suite/screens/views/bar_graph.py b/tests/suite/screens/views/bar_graph.py new file mode 100644 index 000000000..b42d03999 --- /dev/null +++ b/tests/suite/screens/views/bar_graph.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_GRAPH_PLOT +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item + +NEAR: Final[float] = 0.25 +FAR: Final[float] = 0.75 +BAR_MIDDLE: Final[float] = 0.5 +HOVER_SECONDS: Final[float] = 10.0 + + +class MissingGraphError(AssertionError): + """Raised when a scenario reaches for a bar graph the screen draws nowhere.""" + + +@dataclass(frozen=True) +class PlotMapping: + """How a plot lays its values across the screen, read off two points the pointer rested on. + + A plot draws its axes in straight lines, so two readings fix where any value lands. + """ + + screen: Tuple[Point, Point] + plot: Tuple[Tuple[float, float], Tuple[float, float]] + + def point(self, x: float, y: float) -> Point: + """The viewport point the plot draws the value ``(x, y)`` at.""" + (near_x, near_y), (far_x, far_y) = self.plot + near, far = self.screen + return Point( + x=round(near.x + (x - near_x) * (far.x - near.x) / (far_x - near_x)), + y=round(near.y + (y - near_y) * (far.y - near.y) / (far_y - near_y)), + ) + + +class BarGraph: + """A graph of one envelope drawn as bars, one per item, which a press or a drag sets.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self.plot = compose_tag(tag, SUF_GRAPH_PLOT) + + def mapping(self) -> PlotMapping: + """Rests the pointer at two points of the plot and reads the values the plot puts under them.""" + rect = self._bridge.ask(lambda: read_item(self.plot).rect) + if rect is None: + raise MissingGraphError(f"The plot '{self.plot}' reports no box") + + points = ( + Point(x=round(rect.x + rect.width * NEAR), y=round(rect.y + rect.height * NEAR)), + Point(x=round(rect.x + rect.width * FAR), y=round(rect.y + rect.height * FAR)), + ) + readings: List[Tuple[float, float]] = [] + for point in points: + readings.append(self.value_under(point)) + + return PlotMapping(screen=points, plot=(readings[0], readings[1])) + + def value_under(self, point: Point) -> Tuple[float, float]: + """Rests the pointer at ``point`` and reads the value the plot puts under it.""" + self._hand.move_to(point) + self._bridge.expect( + lambda: bool(dpg.is_item_hovered(self.plot)), + bool, + description=f"the pointer resting on '{self.plot}'", + timeout=HOVER_SECONDS, + ) + x, y = self._bridge.ask(dpg.get_plot_mouse_pos) + return float(x), float(y) + + def zoom_in(self, fraction: float, notches: int) -> None: + """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, widening each bar there.""" + rect = self._bridge.ask(lambda: read_item(self.plot).rect) + if rect is None: + raise MissingGraphError(f"The plot '{self.plot}' reports no box") + + self._hand.wheel_at( + Point(x=round(rect.x + rect.width * fraction), y=round(rect.y + rect.height * BAR_MIDDLE)), -notches + ) + + def drag_across( + self, + first: int, + last: int, + *, + value: float, + ) -> None: + """Drags along the height of ``value`` from the bar of item ``first`` to the bar of item ``last``.""" + mapping = self.mapping() + self._hand.drag(mapping.point(first + BAR_MIDDLE, value), mapping.point(last + BAR_MIDDLE, value)) + + def item_under(self, point: Point) -> Tuple[int, float]: + """The item whose bar stands under ``point``, and the value the plot puts there.""" + x, y = self.value_under(point) + return int(x), y + + def drag_item( + self, + index: int, + *, + start: float, + end: float, + ) -> Point: + """Drags the bar of item ``index`` from the value ``start`` to ``end``, and returns where it let go.""" + mapping = self.mapping() + release = mapping.point(index + BAR_MIDDLE, end) + self._hand.drag(mapping.point(index + BAR_MIDDLE, start), release) + return release diff --git a/tests/suite/screens/views/browsers.py b/tests/suite/screens/views/browsers.py index 15bb50501..91179c184 100644 --- a/tests/suite/screens/views/browsers.py +++ b/tests/suite/screens/views/browsers.py @@ -3,7 +3,8 @@ import dearpygui.dearpygui as dpg -from sampletones_core.structures.tree.node import FileSystemNode, LibraryNode, TreeNode +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.structures.tree.node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import TREE_NODE_TYPE, Item, find_item, read_label @@ -34,7 +35,7 @@ def file_row(self, path: Path) -> Optional[Item]: return self._row(lambda node: isinstance(node, FileSystemNode) and node.filepath == path) def file_rows(self, path: Path) -> List[Item]: - """Every row naming the file at ``path``, which a browser listing it under several headings draws more than once.""" + """Every row naming the file at ``path``, which a browser listing it under several headings draws again.""" def rows() -> List[Item]: found: List[Item] = [] @@ -43,6 +44,10 @@ def rows() -> List[Item]: return self._bridge.ask(rows) + def generator_row(self, generator: GeneratorName) -> Optional[Item]: + """The first row standing for ``generator``, if one is drawn.""" + return self._row(lambda node: isinstance(node, GeneratorNode) and node.generator_name == generator) + def library_row(self, matches: Callable[[LibraryNode], bool]) -> Optional[Item]: """The first row standing for a library ``matches`` accepts, if one is drawn.""" return self._row(lambda node: isinstance(node, LibraryNode) and matches(node)) diff --git a/tests/suite/screens/views/instructions.py b/tests/suite/screens/views/instructions.py index 76c006e4c..0e9de8709 100644 --- a/tests/suite/screens/views/instructions.py +++ b/tests/suite/screens/views/instructions.py @@ -1,8 +1,13 @@ -from typing import Optional +from typing import Final, Optional + +import dearpygui.dearpygui as dpg from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.graphs import SUF_GRAPH_Y_AXIS from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM, TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, @@ -17,9 +22,12 @@ from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.notices import Notice from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.views.waveform import Waveform GENERATE_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, SUF_BUTTON) CANCEL_GENERATION_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, SUF_BUTTON) +SPECTRUM_BANDS = compose_tag(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, SUF_GRAPH_Y_AXIS) +BAR_SERIES_TYPE: Final[str] = "mvAppItemType::mvBarSeries" class Library: @@ -58,11 +66,25 @@ def status(self) -> str: class Instructions: - """The Instructions tab, as far as scenarios read it.""" + """The Instructions tab: the library card, and the waveform and spectrum of the instruction picked in it.""" def __init__( self, bridge: Bridge, hand: Hand, ) -> None: + self._bridge = bridge self.library = Library(bridge, hand) + self.waveform = Waveform(bridge, hand, TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM) + + def spectrum_bands(self) -> int: + """How many bands the spectrum draws.""" + + def read() -> int: + return sum( + 1 + for child in dpg.get_item_children(SPECTRUM_BANDS, 1) + if dpg.get_item_info(child)["type"] == BAR_SERIES_TYPE + ) + + return self._bridge.ask(read) diff --git a/tests/suite/screens/views/instruments.py b/tests/suite/screens/views/instruments.py new file mode 100644 index 000000000..98e3d3b14 --- /dev/null +++ b/tests/suite/screens/views/instruments.py @@ -0,0 +1,125 @@ +from typing import Final, Optional, Tuple + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON, SUF_GROUP, SUF_TABLE, SUF_TEXT +from sampletones_application.tags.graphs import SUF_GRAPH, SUF_GRAPH_RAW_DATA +from sampletones_application.tags.reconstructions import ( + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, + TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, + TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_selected_tab, read_theme, read_value +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.bar_graph import BarGraph + +AUDITION_GROUP: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP) + + +class Instruments: + """The instruments card: a tab per channel, each with a graph and a field per envelope it governs.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def bring_forward(self, channel: ChannelName) -> None: + """Clicks the tab of ``channel``.""" + self._hand.click(tab(channel)) + + def front(self) -> str: + """The tag of the tab standing in front.""" + return self._bridge.ask(lambda: read_selected_tab(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR)) + + def tab_shown(self, channel: ChannelName) -> bool: + return self._bridge.ask(lambda: read_item(tab(channel))).shown + + def tab_theme(self, channel: ChannelName) -> Optional[str]: + """The theme ``channel``'s tab wears, which mutes the tab of a channel that plays nothing.""" + return self._bridge.ask(lambda: read_theme(tab(channel))) + + def size(self, channel: ChannelName) -> str: + """What ``channel``'s tab reads as the size of the instrument it exports.""" + return str( + self._bridge.ask( + lambda: read_value(compose_tag(tab(channel), SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE)) + ) + ) + + def rows(self, channel: ChannelName) -> Tuple[FeatureKey, ...]: + """The envelopes ``channel``'s tab draws a row for, top to bottom.""" + + def read() -> Tuple[FeatureKey, ...]: + return tuple(feature for feature in FeatureKey if read_item(_row(channel, feature)).shown) + + return self._bridge.ask(read) + + def field(self, channel: ChannelName, feature: FeatureKey) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH_RAW_DATA, SUF_TEXT) + + def envelope(self, channel: ChannelName, feature: FeatureKey) -> str: + """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" + return str(self._bridge.ask(lambda: read_value(self.field(channel, feature)))) + + def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str]: + """The theme the field wears, which marks a sequence refused or one an export shortens.""" + return self._bridge.ask(lambda: read_theme(self.field(channel, feature))) + + def type_envelope(self, channel: ChannelName, feature: FeatureKey, sequence: str) -> None: + """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses Enter.""" + self.bring_forward(channel) + field = self.field(channel, feature) + self._hand.scroll_into_view(field) + self._hand.replace_text(field, sequence) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + def graph(self, channel: ChannelName, feature: FeatureKey) -> BarGraph: + return BarGraph( + self._bridge, self._hand, compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH) + ) + + def offers_pitch_stepper(self, channel: ChannelName) -> bool: + """Whether ``channel``'s tab offers the stepper setting the pitch its arpeggio is read against.""" + window = compose_tag(tab(channel), SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW) + return self._bridge.ask(lambda: read_item(compose_tag(window, SUF_TABLE))).shown + + def offers_audition(self) -> bool: + """Whether the Audition switch stands, which it does while a hand-written instrument is open.""" + return self._bridge.ask(lambda: read_item(AUDITION_GROUP)).shown + + def audition(self) -> str: + """The generator the Audition switch names.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION))) + + def choose_audition(self, label: str) -> None: + self._bridge.ask(lambda: choose(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, label, CallbackQueue.run)) + + def can_export(self, channel: ChannelName) -> bool: + """Whether ``channel``'s Export instrument button answers.""" + return self._bridge.ask(lambda: read_item(export_button(channel))).enabled + + def export(self, channel: ChannelName) -> None: + self._hand.scroll_into_view(export_button(channel)) + self._hand.click(export_button(channel)) + + +def tab(channel: ChannelName) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel) + + +def export_button(channel: ChannelName) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab(channel), SUF_BUTTON) + + +def _row(channel: ChannelName, feature: FeatureKey) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GROUP) diff --git a/tests/suite/screens/views/reconstructions.py b/tests/suite/screens/views/reconstructions.py index 365a3b721..d19d88961 100644 --- a/tests/suite/screens/views/reconstructions.py +++ b/tests/suite/screens/views/reconstructions.py @@ -3,31 +3,33 @@ from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( - SUF_GROUP, - SUF_TEXT, SUF_TOOLTIP, TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, ) -from sampletones_application.tags.graphs import SUF_GRAPH_RAW_DATA from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_CHANNEL, TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION, TAG_RECONSTRUCTIONS_BROWSER_TREE, - TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, - TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import read_item, read_texts, read_value -from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.instruments import Instruments from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.views.stems import StemsCard +from tests.suite.screens.views.waveform import Waveform OPEN_FILE_TOOLTIP = compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, SUF_TOOLTIP) @@ -54,6 +56,9 @@ def __init__( self.replaced_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED) self.remove_prompt = Prompt(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION) self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED) + self.waveform = Waveform(bridge, hand, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM) + self.instruments = Instruments(bridge, hand) + self.stems = StemsCard(bridge, hand) def open_from_menu(self) -> None: """Chooses Reconstruction โ–ธ Open, which asks for a file.""" @@ -106,33 +111,30 @@ def retune_lock_words(self) -> str: self._bridge.ask(lambda: read_texts(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)) ) - def offers_audition(self) -> bool: - """Whether the Audition switch stands, which it does while a hand-written instrument is open.""" - return self._bridge.ask( - lambda: read_item(compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP)) - ).shown + def audio_source(self) -> str: + """The source the switch above the waveform names: the reconstruction, or the original audio.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE))) - def envelope( - self, - channel: ChannelName, - feature: FeatureKey, - ) -> str: - """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" - return str(self._bridge.ask(lambda: read_value(_envelope_field(channel, feature)))) + def can_choose_audio_source(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE)).enabled - def type_envelope( - self, - channel: ChannelName, - feature: FeatureKey, - sequence: str, - ) -> None: - """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses Enter.""" - self._hand.click(compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel)) - field = _envelope_field(channel, feature) - self._hand.scroll_into_view(field) - self._hand.replace_text(field, sequence) - self._hand.press_key(IMGUI_ENTER, modifiers=[]) + def choose_audio_source(self, label: str) -> None: + """Picks the source the switch names ``label``.""" + self._bridge.ask( + lambda: choose(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, label, CallbackQueue.run) + ) + + def channel_ticked(self, channel: ChannelName) -> bool: + """Whether the box of ``channel`` above the waveform stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) + + def channel_plays(self, channel: ChannelName) -> bool: + """Whether the box of ``channel`` answers, which it does for a channel the reconstruction plays.""" + return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled + + def tick_channel(self, channel: ChannelName) -> None: + self._hand.click(_channel_box(channel)) -def _envelope_field(channel: ChannelName, feature: FeatureKey) -> str: - return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH_RAW_DATA, SUF_TEXT) +def _channel_box(channel: ChannelName) -> str: + return compose_tag(PRE_RECONSTRUCTION_CHANNEL, channel.value) diff --git a/tests/suite/screens/views/stems.py b/tests/suite/screens/views/stems.py new file mode 100644 index 000000000..c8dc7935b --- /dev/null +++ b/tests/suite/screens/views/stems.py @@ -0,0 +1,80 @@ +from typing import Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_GROUP, SUF_SWATCH, SUF_TEXT +from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_STEMS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, +) +from sampletones_application.ui.elements.stems.tags import StemsTags +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import enclosing_regions, read_item, read_label, read_value +from tests.suite.screens.views.prompts import Prompt + + +class StemsCard: + """The Stems card of the Reconstructions tab: a row per recording of the open reconstruction. + + A row is known by the key the card gives its recording, which is the recording's number in + the reconstruction. The card is the last of its column, so a gesture on a row first scrolls the + column to its end, as a person scrolls down to it. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_RECONSTRUCTION_STEMS) + self.remove_prompt = Prompt(bridge, hand, TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION) + + def has_row(self, key: str) -> bool: + reading = self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_GROUP))) + return reading.exists and reading.shown + + def name(self, key: str) -> str: + return self._bridge.ask(lambda: read_label(self.tags.row(key, SUF_TEXT))) + + def is_highlighted(self, key: str) -> bool: + """Whether the row's name stands highlighted, as a picked row does.""" + return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_TEXT)))) + + def swatch(self, key: str) -> Tuple[float, ...]: + """The color the row's swatch is filled with.""" + return tuple( + float(part) + for part in self._bridge.ask(lambda: dpg.get_item_configuration(self.tags.row(key, SUF_SWATCH))["fill"]) + ) + + def can_remove(self, key: str) -> bool: + """Whether the row's remove button answers.""" + return self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_BUTTON))).enabled + + def remove(self, key: str) -> None: + """Clicks the row's remove button, which asks first.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_BUTTON))) + + def click(self, key: str) -> None: + self._hand.click(self._in_view(self.tags.row(key, SUF_TEXT))) + + def reveal(self, key: str) -> None: + """Double-clicks the row's name, which shows its recording in the file manager.""" + self._hand.double_click(self._in_view(self.tags.row(key, SUF_TEXT))) + + def hears(self, key: str) -> bool: + """Whether the row's box stands ticked, which is whether its recording is heard.""" + return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_CHECKBOX)))) + + def tick(self, key: str) -> None: + self._hand.click(self._in_view(self.tags.row(key, SUF_CHECKBOX))) + + def _in_view(self, item: str) -> str: + """Scrolls the column the card stands in to its end and ``item`` into view, and returns ``item``.""" + column = self._bridge.ask(lambda: enclosing_regions(self.tags.well))[-1] + self._hand.scroll_to_end(column) + self._hand.scroll_into_view(item) + return item diff --git a/tests/suite/screens/views/waveform.py b/tests/suite/screens/views/waveform.py new file mode 100644 index 000000000..06274e0c0 --- /dev/null +++ b/tests/suite/screens/views/waveform.py @@ -0,0 +1,141 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import ( + SUF_GRAPH_PLOT, + SUF_GRAPH_X_AXIS, + SUF_GRAPH_Y_AXIS, + SUF_WAVEFORM_OVERLAY, + SUF_WAVEFORM_POSITION_INDICATOR, +) +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge +from tests.suite.screens.dearpygui.geometry import Point, Rect +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item + +LINE_SERIES_TYPE: Final[str] = "mvAppItemType::mvLineSeries" +FIRST_POINT: Final[int] = 0 +Y_VALUES: Final[int] = 1 +MIDDLE: Final[float] = 0.5 +SETTLING_SECONDS: Final[float] = 10.0 + + +class MissingPlotError(AssertionError): + """Raised when a scenario reaches for a waveform the screen draws nowhere.""" + + +class Waveform: + """A waveform graph: where it stands, what it draws, and the cursor marking the sample that plays. + + Positions along the graph are read in samples, which is what the graph's x axis counts. A + gesture names a point by the fraction of the plot's width it lies at, halfway down the plot. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tag = tag + self.plot = compose_tag(tag, SUF_GRAPH_PLOT) + self.x_axis = compose_tag(tag, SUF_GRAPH_X_AXIS) + self.y_axis = compose_tag(tag, SUF_GRAPH_Y_AXIS) + self.cursor_line = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) + self.overlay = compose_tag(tag, SUF_WAVEFORM_OVERLAY) + + def point(self, fraction: float) -> Point: + """The viewport point ``fraction`` of the way across the plot, halfway down it.""" + box = self._box() + return Point(x=round(box.x + box.width * fraction), y=round(box.y + box.height * MIDDLE)) + + def sample_under(self, fraction: float) -> float: + """Rests the pointer ``fraction`` of the way across the plot and reads the sample the plot puts under it. + + Raises: + ExpectationError: If the plot never reports the pointer resting on it. + """ + self._hand.move_to(self.point(fraction)) + self._bridge.expect( + lambda: bool(dpg.is_item_hovered(self.plot)), + bool, + description=f"the pointer resting on '{self.plot}'", + timeout=SETTLING_SECONDS, + ) + return float(self._bridge.ask(dpg.get_plot_mouse_pos)[FIRST_POINT]) + + def cursor(self) -> Optional[float]: + """The sample the cursor marks, or ``None`` while no cursor stands on the plot.""" + return self._bridge.ask(lambda: read_cursor(self.cursor_line)) + + def limits(self) -> Tuple[float, float]: + """The first and the last sample the plot shows.""" + low, high = self._bridge.ask(lambda: dpg.get_axis_limits(self.x_axis)) + return float(low), float(high) + + def series(self) -> Tuple[str, ...]: + """The tags of the lines the plot draws, in the order it draws them, the last on top.""" + + def read() -> Tuple[str, ...]: + return tuple( + str(dpg.get_item_alias(child)) + for child in dpg.get_item_children(self.y_axis, 1) + if dpg.get_item_info(child)["type"] == LINE_SERIES_TYPE + ) + + return self._bridge.ask(read) + + def series_tag(self, layer: str) -> str: + """The tag of the line the plot draws for ``layer``.""" + return compose_tag(self.y_axis, layer) + + def drawn(self, layer: str) -> List[float]: + """The heights the plot draws ``layer`` at, one per point along it.""" + return [float(value) for value in self._bridge.ask(lambda: dpg.get_value(self.series_tag(layer)))[Y_VALUES]] + + def zoom_in(self, fraction: float, notches: int) -> None: + """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, narrowing what it shows.""" + self._hand.wheel_at(self.point(fraction), -notches) + + def click(self, fraction: float) -> None: + self._hand.click_at(self.point(fraction)) + + def double_click(self, fraction: float) -> None: + self._hand.double_click_at(self.point(fraction)) + + def triple_click(self, fraction: float) -> None: + self._hand.triple_click_at(self.point(fraction)) + + def drag(self, start: float, end: float) -> None: + """Drags across the plot from ``start`` to ``end``, each a fraction of its width.""" + self._hand.drag(self.point(start), self.point(end)) + + def _box(self) -> Rect: + """The plot's box once it stands where it stood a frame before, as a layout settling after a load leaves it.""" + settled = self._bridge.expect( + self._two_frames_of_box, + lambda boxes: boxes[0] is not None and boxes[0] == boxes[1], + description=f"the plot '{self.plot}' standing still", + timeout=SETTLING_SECONDS, + )[0] + if settled is None: + raise MissingPlotError(f"The plot '{self.plot}' reports no box") + + return settled + + def _two_frames_of_box(self) -> Tuple[Optional[Rect], Optional[Rect]]: + first = self._bridge.ask(lambda: read_item(self.plot).rect) + self._bridge.frames(ONE_FRAME) + return first, self._bridge.ask(lambda: read_item(self.plot).rect) + + +def read_cursor(cursor_line: str) -> Optional[float]: + """The sample ``cursor_line`` marks in this frame, or ``None`` while it stands hidden. Runs on the render thread.""" + if not read_item(cursor_line).shown: + return None + + return float(dpg.get_value(cursor_line)[FIRST_POINT][FIRST_POINT]) diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py index 8680f49aa..40e83c6e6 100644 --- a/tests/suite/screens/world.py +++ b/tests/suite/screens/world.py @@ -11,7 +11,14 @@ from sampletones_core.configs import Config from sampletones_shared.paths.user import CONFIG_PATH, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.screens.environment import SCREEN_SIZE -from tests.suite.screens.seeds import MiniLibrary, Recording, StoredProject, StoredReconstruction, stored_recording +from tests.suite.screens.seeds import ( + MiniLibrary, + PlayableReconstruction, + Recording, + StoredProject, + StoredReconstruction, + stored_recording, +) OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" OTHER_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Other.stn" @@ -22,6 +29,16 @@ LEAD: Final[str] = "lead.wav" DOCUMENT_RECORDING_SECONDS: Final[float] = 0.3 DOCUMENT_RECORDING_FREQUENCY: Final[float] = 220.0 +PLAYABLE_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Playable.stn" +SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn" +SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn" +TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") +STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") +STEM_FRAMES: Final[int] = 60 +PLAYABLE_FRAMES: Final[int] = 600 +FRAMES_PER_SECOND: Final[int] = 60 +TAKE_FREQUENCY: Final[float] = 220.0 class HomeFile(Protocol): @@ -134,3 +151,35 @@ def documents_world() -> World: StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), ) ) + + +def playing_world() -> World: + """A home holding reconstructions that play: two of one recording each, and one of three recordings in turn. + + :data:`PLAYABLE_RECONSTRUCTION` and :data:`SECOND_PLAYABLE` play the first and the second of + :data:`TAKES`, the shorter :data:`STEMS_RECONSTRUCTION` plays the three :data:`STEM_TAKES` in + turn, and :data:`SHORT_RECONSTRUCTION` plays the first of them alone. The home holds the stored + :data:`OPEN_RECONSTRUCTION`, sounding every channel, and the project :data:`SONG` besides. + """ + takes = tuple(Path.cwd() / name for name in TAKES) + stem_takes = tuple(Path.cwd() / name for name in STEM_TAKES) + recordings = tuple( + Recording(destination=take, seconds=frames / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY * (index + 1)) + for paths, frames in ((takes, PLAYABLE_FRAMES), (stem_takes, STEM_FRAMES)) + for index, take in enumerate(paths) + ) + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=( + *recordings, + PlayableReconstruction(PLAYABLE_RECONSTRUCTION, takes[:1], PLAYABLE_FRAMES), + PlayableReconstruction(SECOND_PLAYABLE, takes[1:2], PLAYABLE_FRAMES), + PlayableReconstruction(STEMS_RECONSTRUCTION, stem_takes, STEM_FRAMES), + PlayableReconstruction(SHORT_RECONSTRUCTION, stem_takes[:1], STEM_FRAMES), + StoredReconstruction(OPEN_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ), + ) From 1728fe775c39cd3e5e4be8e33339ce4c0dd48168 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 18:03:24 +0200 Subject: [PATCH 11/19] Proved: the Sequencer's tracker, voices, history and song on a live screen --- docs/development/application/screens.md | 7 +- docs/development/bugs-and-todos.md | 16 + tests/screens/sequencer/__init__.py | 0 tests/screens/sequencer/conftest.py | 9 + tests/screens/sequencer/test_history.py | 291 ++++++++++++++ tests/screens/sequencer/test_song.py | 286 +++++++++++++ tests/screens/sequencer/test_tracker.py | 421 ++++++++++++++++++++ tests/screens/sequencer/test_voices.py | 274 +++++++++++++ tests/suite/screens/dearpygui/hand.py | 19 +- tests/suite/screens/dearpygui/plots.py | 47 +++ tests/suite/screens/screen.py | 13 +- tests/suite/screens/seeds.py | 57 +++ tests/suite/screens/views/audio_settings.py | 60 +++ tests/suite/screens/views/bar_graph.py | 12 +- tests/suite/screens/views/context_menu.py | 38 +- tests/suite/screens/views/history.py | 94 +++++ tests/suite/screens/views/menus.py | 82 +++- tests/suite/screens/views/project.py | 7 + tests/suite/screens/views/sequencer.py | 124 +++++- tests/suite/screens/views/tracker.py | 117 ++++++ tests/suite/screens/views/waveform.py | 10 +- tests/suite/screens/world.py | 26 ++ 22 files changed, 1977 insertions(+), 33 deletions(-) create mode 100644 tests/screens/sequencer/__init__.py create mode 100644 tests/screens/sequencer/conftest.py create mode 100644 tests/screens/sequencer/test_history.py create mode 100644 tests/screens/sequencer/test_song.py create mode 100644 tests/screens/sequencer/test_tracker.py create mode 100644 tests/screens/sequencer/test_voices.py create mode 100644 tests/suite/screens/dearpygui/plots.py create mode 100644 tests/suite/screens/views/audio_settings.py create mode 100644 tests/suite/screens/views/history.py create mode 100644 tests/suite/screens/views/tracker.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 5086a2ce4..9fc3e5409 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -79,7 +79,9 @@ Every scenario is held to the same after-checks. A failing gesture is logged and keeps running, which is why the checks read the log as well as the screen. - **Quiet.** The application logged no error and no thread let an exception escape. A scenario that - provokes a failure claims the error it provokes, and an error nobody claims still fails it. + provokes a failure claims the error it provokes, and an error nobody claims still fails it. A defect + the bugs ledger records, which a scenario's gestures meet by chance, is forgiven by name in the + scenarios about something else, while the ledger entry names it for its fix. - **Contained.** The application started no program, and every file dialog it opened had an answer waiting. - **Settled.** No modal conversation is left open, and every window lies inside the viewport. @@ -136,6 +138,9 @@ few frames, since a busy display hands input on late. Each step of a drag waits reads the pointer where the step put it, and the pointer rests at the end before the button comes up, as a person stops before letting go. A press lost on the way fails where it was lost. +A plot reads the pointer as it draws, so the value it puts under the pointer trails the pointer by a +frame. A scenario that aims by plot values reads one once the plot has kept it for two frames in a row. + DearPyGui reports a position alone for a menu entry, so choosing one runs its callback the way a click does, through the queue's own error reporting. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 77e0ed6fe..9992773ea 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -202,3 +202,19 @@ currently out of line. An entry leaves when the code meets the contract again. combination ending in a note key: the instruments panel answers a key whatever modifiers are held, so Undo, Save and the rest never reach their shortcuts. `tests/screens/reconstructions/test_instruments.py` reproduces it as a known failure. +* Playing the song from a tracker row leaves the Playback menu reading Play, with Stop greyed out, while + the song plays: that path never refreshes the menu. `tests/screens/sequencer/test_tracker.py` reproduces + it as a known failure. +* New instrument with no project open writes into a project nobody opened: the Voices card's button has + no open-project guard, so the instrument is added and listed, while Voice โ–ธ New instrument stands + greyed out. `tests/screens/sequencer/test_voices.py` reproduces it as a known failure. +* The history lines of a renamed or a moved voice name it one way alone: a rename names the voice and + not its position, and a move names the positions and not the voice. + `tests/screens/sequencer/test_history.py` reproduces it as a known failure. +* A project opened as the application starts is saved as though it had no file: `load_project_safely` + leaves the session's current project unset, so Save asks for a path, or writes to whatever path an + earlier session left. `tests/screens/sequencer/test_song.py` reproduces it as a known failure. +* A voice row's hover can log an error: the Voices list rebuilds every row on each update, and a hover + callback queued for a row before the rebuild reads an item that no longer exists + (`_on_row_hovered`, "Item not found"). The Sequencer's screen scenarios forgive it by name; no + scenario reproduces it on demand, since it rests on the order the queued callbacks run in. diff --git a/tests/screens/sequencer/__init__.py b/tests/screens/sequencer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/conftest.py b/tests/screens/sequencer/conftest.py new file mode 100644 index 000000000..e191b2922 --- /dev/null +++ b/tests/screens/sequencer/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.world import World, sequencer_world + + +@pytest.fixture +def world() -> World: + """The home every Sequencer scenario starts in: an arranged project, and reconstructions to add to it.""" + return sequencer_world() diff --git a/tests/screens/sequencer/test_history.py b/tests/screens/sequencer/test_history.py new file mode 100644 index 000000000..2d354696a --- /dev/null +++ b/tests/screens/sequencer/test_history.py @@ -0,0 +1,291 @@ +import operator +from functools import partial +from typing import Callable, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import titled +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.views.history import HistoryLine +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +SETTLING_FRAMES: Final[int] = 10 +RENAMED: Final[str] = "Renamed" +DUPLICATE: Final[str] = "sequencer.voices.label.context_duplicate" +HOVER_RACE: Final[str] = "Error executing callback _on_row_hovered" +VOLUME_LETTER: Final[str] = "v" +ARPEGGIO_LETTER: Final[str] = "a" +QUIET: Final[float] = 4.0 +ARPEGGIO_STEP: Final[float] = 5.0 +DRAGGED_ITEM: Final[int] = 1 +LINE_POSITION: Final[str] = "00" +PAD_POSITION: Final[str] = "02" +POSITION_MARK: Final[str] = ":" + +Gesture = Callable[[Screen, str], None] + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def pick(screen: Screen, name: str) -> None: + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + row = screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + voices.pick(row) + screen.frames(SETTLING_FRAMES) + + +def rename(screen: Screen, name: str) -> None: + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) + + +def duplicate(screen: Screen, name: str) -> None: + voices = screen.sequencer.voices + menu = screen.context_menu + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(screen.expect_item(partial(voices.row, name), description=f"the row of {name}")) + screen.expect(menu.is_shown, bool, description="the row's menu") + menu.choose(screen.words(DUPLICATE)) + + +def move_down(screen: Screen, name: str) -> None: + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def move(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place, down where a place lies below it and up otherwise.""" + last = screen.sequencer.voices.names()[-1] == name + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP if last else ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def remove(screen: Screen, name: str) -> None: + prompt = screen.sequencer.voices.remove_prompt + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + prompt.confirm() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + + +GESTURES: Final[Tuple[Tuple[str, Gesture], ...]] = ( + ("rename", rename), + ("duplicate", duplicate), + ("move", move), + ("remove", remove), +) + + +def done(lines: Tuple[HistoryLine, ...]) -> Tuple[HistoryLine, ...]: + """The lines of the entries done, from the one in force down, which leaves out those an undo set aside.""" + current = next(index for index, line in enumerate(lines) if line.current) + return lines[current:] + + +def voice_segment_color(line: HistoryLine, name: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice ``name``.""" + return next(segment.color for segment in line.segments if name in segment.words) + + +def position_color(line: HistoryLine, position: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice by its ``position``.""" + return next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + + +def naming_color(line: HistoryLine, voice: str, position: str) -> Tuple[float, ...]: + """The color of the first piece of ``line`` naming the voice, by its name or by its ``position``.""" + return next( + segment.color + for segment in line.segments + if voice in segment.words or segment.words.rstrip(POSITION_MARK) == position + ) + + +def one_more_than(count: int) -> Callable[[Tuple[HistoryLine, ...]], bool]: + """Whether a reading of the lines done holds one more than ``count``.""" + return lambda lines: len(lines) == count + 1 + + +def names_by_position_and_name(line: HistoryLine, voice: str, position: str) -> bool: + words = [segment.words.rstrip(POSITION_MARK) for segment in line.segments] + return position in words and any(voice in word for word in words) + + +def one_entry_per_gesture(gesture_name: str, gesture: Gesture, voice: str, position: str) -> Callable[[Screen], None]: + """The step making ``gesture`` on ``voice``: one entry naming it in its kind's color, undone and redone at once.""" + + def step(screen: Screen) -> None: + voices = screen.sequencer.voices + history = screen.sequencer.history + before_names = voices.names() + before = done(history.lines()) + kind = voices.kind_color(voice) + + gesture(screen, voice) + + after = screen.expect( + lambda: done(history.lines()), lambda lines: len(lines) == len(before) + 1, description="one entry" + ) + after_names = voices.names() + assert after_names != before_names + assert [line.segments for line in after[1:]] == [line.segments for line in before] + assert naming_color(after[0], voice, position) == kind + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, before_names.__eq__, description=f"{gesture_name} undone") + assert [line.segments for line in done(history.lines())] == [line.segments for line in before] + screen.press_shortcut(ShortcutId.REDO) + screen.expect(voices.names, after_names.__eq__, description=f"{gesture_name} redone") + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, before_names.__eq__, description=f"{gesture_name} undone again") + + step.__name__ = f"{gesture_name}_{voice.lower()}_is_one_entry" + return step + + +def leave_as_opened(screen: Screen) -> None: + """Exits a project every gesture of which was undone, which leaves at once.""" + screen.forgive_known_error(HOVER_RACE) + assert screen.title() == titled(screen, ARRANGED_PROJECT.stem) + + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +def leave_letting_the_project_go(screen: Screen) -> None: + screen.forgive_known_error(HOVER_RACE) + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestEachVoiceGestureIsOneEntry: + """Renaming, duplicating, moving and removing a sample or an instrument each make one entry. + + The entry names the voice in the color of its kind, and one undo and one redo take it back and + forth. + """ + + def test_a_sample_and_an_instrument(self, screen: Screen) -> None: + steps = [ + one_entry_per_gesture(name, gesture, voice, position) + for voice, position in ((LINE, LINE_POSITION), (PAD, PAD_POSITION)) + for name, gesture in GESTURES + ] + + screen.scenario(*steps, leave_as_opened).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: the history lines of a renamed or a moved voice name it one way alone", + ) + def test_every_line_names_the_voice_by_position_and_name(self, screen: Screen) -> None: + history = screen.sequencer.history + lines: List[HistoryLine] = [] + for gesture in (rename, move_down): + before = len(done(history.lines())) + gesture(screen, LINE) + lines.append( + screen.expect(lambda: done(history.lines()), one_more_than(before), description="its entry")[0] + ) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(screen.sequencer.voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="undone") + + screen.forgive_known_error(HOVER_RACE) + assert all(names_by_position_and_name(line, LINE, LINE_POSITION) for line in lines) + + def test_a_new_instrument_is_one_entry(self, screen: Screen) -> None: + voices = screen.sequencer.voices + history = screen.sequencer.history + + def add_one(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + before = history.lines() + + voices.new_instrument() + + after = screen.expect(history.lines, lambda lines: len(lines) == len(before) + 1, description="one entry") + assert len(voices.names()) == 4 + name = voices.names()[-1] + assert voice_segment_color(after[0], name) == voices.kind_color(name) + assert voices.kind_color(name) == voices.kind_color(PAD) + + def undo_takes_it_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the instrument gone") + + screen.scenario(add_one, undo_takes_it_away, leave_as_opened).run() + + +class TestAnEnvelopeDragIsOneEntry: + """A drag across an instrument's envelope makes one entry naming the voice and the dimension; another, another. + + The entry names the instrument by its number, in the color of its kind. + """ + + def test_one_entry_per_dimension(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + history = screen.sequencer.history + lines: List[int] = [] + standing: List[str] = [] + + def drag(feature: FeatureKey, end: float) -> None: + graph = instruments.graph(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(graph.plot) + graph.drag_item(DRAGGED_ITEM, start=0.0, end=end) + + def drag_the_volume(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + lines.append(len(history.lines())) + + drag(FeatureKey.VOLUME, QUIET) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 1, description="one entry") + assert position_color(after[0], PAD_POSITION) == screen.sequencer.voices.kind_color(PAD) + assert VOLUME_LETTER in after[0].words.split() + + def drag_the_arpeggio(screen: Screen) -> None: + standing.append(instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO)) + + drag(FeatureKey.ARPEGGIO, ARPEGGIO_STEP) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 2, description="a second entry") + assert ARPEGGIO_LETTER in after[0].words.split() + + def one_undo_takes_one_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(lambda: history.lines()[1].current, bool, description="one step back") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) == standing[0] + + screen.scenario(drag_the_volume, drag_the_arpeggio, one_undo_takes_one_back, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/test_song.py b/tests/screens/sequencer/test_song.py new file mode 100644 index 000000000..0c484c230 --- /dev/null +++ b/tests/screens/sequencer/test_song.py @@ -0,0 +1,286 @@ +import math +import operator +from functools import partial +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.utils.display import BLANK +from tests.suite.screens.application import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import marked, raise_the_first_level, titled, voice_title +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.world import ARRANGED_PROJECT, LINE + +SETTLING_FRAMES: Final[int] = 20 +EMPTY_VOICE: Final[str] = ".." +FIRST_PATTERN: Final[str] = "00" +SECOND_PATTERN: Final[str] = "01" +LINE_NUMBER: Final[str] = "00" +FIRST_HIGHLIGHT: Final[int] = 3 +SECOND_HIGHLIGHT: Final[int] = 6 +TINTED_ROWS_READ: Final[int] = 13 +UNITY: Final[float] = 1.0 +LOUDEST_GAIN: Final[float] = 2.0 +BEYOND_THE_LEFT: Final[float] = -0.5 +BEYOND_THE_RIGHT: Final[float] = 1.5 +NEAR_THE_RIGHT: Final[float] = 0.97 +FROM_THE_MIDDLE: Final[float] = 0.5 +DECIBELS_PER_DOUBLING: Final[float] = 20.0 +LINE_ORDINAL: Final[int] = 0 +FIFTY_HERTZ: Final[int] = 50 +SIXTY_HERTZ: Final[int] = 60 +REFIT_TOLERANCE: Final[float] = 0.02 +SILENT_GAIN: Final[str] = "settings.audio.message.master_gain_silent" +GAIN_TEMPLATE: Final[str] = "settings.audio.template.master_gain_db" +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def on_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") + + +def leave_letting_the_project_go(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestTheOrderTable: + """An order entry typed names the pattern a channel plays at that frame, and the tracker follows it.""" + + def test_patterns_follow_the_order(self, screen: Screen) -> None: + order = screen.sequencer.order + tracker = screen.sequencer.tracker + + def a_new_pattern_number_shows_a_new_pattern(screen: Screen) -> None: + on_the_sequencer(screen) + assert order.label(ChannelName.PULSE1, 0) == FIRST_PATTERN + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(SECOND_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), SECOND_PATTERN.__eq__, description="pattern 01") + assert tracker.label(0, ChannelName.PULSE1, SubColumn.VOICE) == EMPTY_VOICE + + def the_first_number_brings_the_line_back(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(FIRST_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), FIRST_PATTERN.__eq__, description="pattern 00") + screen.expect( + partial(tracker.label, 0, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the line back", + ) + + screen.scenario( + a_new_pattern_number_shows_a_new_pattern, + the_first_number_brings_the_line_back, + leave_letting_the_project_go, + ).run() + + +class TestAVolumeWhereNoSamplePlays: + """A volume typed in the Sample column of an empty frame keeps the cell, records nothing and makes no pattern.""" + + def test_the_frame_stays_empty(self, screen: Screen) -> None: + order = screen.sequencer.order + tracker = screen.sequencer.tracker + history = screen.sequencer.history + before: List[int] = [] + + def add_an_empty_frame(screen: Screen) -> None: + on_the_sequencer(screen) + order.click(ChannelName.PULSE1, 0) + + screen.press_shortcut(ShortcutId.ORDER_ADD_FRAME) + + screen.expect(order.positions, (2).__eq__, description="a second frame") + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + def type_a_volume_on_it(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 1) + before.append(len(history.lines())) + tracker.click(0, SAMPLE_COLUMN, SubColumn.VOLUME) + + screen.hand.type_text("5") + + screen.frames(SETTLING_FRAMES) + assert tracker.label(0, SAMPLE_COLUMN, SubColumn.VOLUME) == BLANK + assert len(history.lines()) == before[0] + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + screen.scenario(add_an_empty_frame, type_a_volume_on_it, leave_letting_the_project_go).run() + + +class TestTheHighlights: + """The tracker tints the rows the project's highlights name, and those alone.""" + + def test_rows_on_the_highlight_are_tinted(self, screen: Screen) -> None: + properties = screen.project.properties + tracker = screen.sequencer.tracker + + def set_highlights_of_three_and_six(screen: Screen) -> None: + on_the_sequencer(screen) + properties.open() + screen.expect(properties.is_shown, bool, description="Project properties") + + properties.retype_highlights(FIRST_HIGHLIGHT, SECOND_HIGHLIGHT) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="Project properties closed") + + def every_third_row_is_tinted(screen: Screen) -> None: + expected = [row % FIRST_HIGHLIGHT == 0 for row in range(TINTED_ROWS_READ)] + + screen.expect( + lambda: [tracker.is_row_tinted(row) for row in range(TINTED_ROWS_READ)], + expected.__eq__, + description="every third row tinted", + ) + + screen.scenario(set_highlights_of_three_and_six, every_third_row_is_tinted, leave_letting_the_project_go).run() + + +class TestTheMasterGain: + """The decibel line follows the master gain: silence at the bottom, the level above unity in the warning color.""" + + def test_the_line_follows_the_slider(self, screen: Screen) -> None: + settings = screen.audio_settings + unity_color: List[Tuple[float, ...]] = [] + + def open_audio_settings(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) + screen.expect(settings.is_shown, bool, description="Audio settings") + + assert settings.gain() == UNITY + unity_color.append(settings.decibels_color()) + + def all_the_way_down_reads_silence(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_LEFT) + + screen.expect(settings.gain, (0.0).__eq__, description="the gain at zero") + assert settings.decibels() == screen.words(SILENT_GAIN) + assert settings.decibels_color() == unity_color[0] + + def all_the_way_up_reads_the_level_in_warning(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_RIGHT) + + screen.expect(settings.gain, LOUDEST_GAIN.__eq__, description="the gain at its top") + level = DECIBELS_PER_DOUBLING * math.log10(LOUDEST_GAIN) + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format(decibels=level) + assert settings.decibels_color() != unity_color[0] + + def back_to_unity(screen: Screen) -> None: + settings.drag_gain(NEAR_THE_RIGHT, FROM_THE_MIDDLE) + + gain = screen.expect(settings.gain, lambda found: 0.0 < found < LOUDEST_GAIN, description="a middle gain") + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format( + decibels=DECIBELS_PER_DOUBLING * math.log10(gain) + ) + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + + screen.scenario( + open_audio_settings, + all_the_way_down_reads_silence, + all_the_way_up_reads_the_level_in_warning, + back_to_unity, + ).run() + + +class TestRetuningWithASampleOpen: + """A new NES frequency retunes the sample open on the Reconstructions tab: it re-fits, keeping the edit before. + + The edit is made on the noise, whose last frame is silent, so the sample keeps its frames and + the waveform stretches by the ratio of the two rates. + """ + + def test_the_waveform_refits_and_the_edit_stays(self, screen: Screen) -> None: + module = screen.sequencer.module + reconstructions = screen.reconstructions + typed: List[str] = [] + limits: List[Tuple[float, float]] = [] + + def edit_the_line(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=False).__eq__, + description="the line open", + ) + + typed.append( + raise_the_first_level( + screen, + ChannelName.NOISE, + title=voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=True), + ) + ) + limits.append(reconstructions.waveform.limits()) + + def retune_to_fifty(screen: Screen) -> None: + on_the_sequencer(screen) + assert module.nes_frequency() == SIXTY_HERTZ + + module.retype_nes_frequency(FIFTY_HERTZ) + + screen.expect(module.retune_prompt.is_shown, bool, description="the question about retuning") + module.retune_prompt.confirm() + screen.expect(module.retune_prompt.is_shown, operator.not_, description="the question answered") + + def the_waveform_refits_and_the_edit_stays(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + low, high = limits[0] + + refitted = screen.expect( + reconstructions.waveform.limits, + lambda found: abs(found[1] / high - SIXTY_HERTZ / FIFTY_HERTZ) <= REFIT_TOLERANCE, + description="the waveform re-fitted to the longer frames", + ) + assert refitted[0] == low + assert reconstructions.instruments.envelope(ChannelName.NOISE, FeatureKey.VOLUME) == typed[0] + + screen.scenario( + edit_the_line, retune_to_fifty, the_waveform_refits_and_the_edit_stays, leave_letting_the_project_go + ).run() + + +class TestSavingAProjectOpenedAtStart: + """A project opened as the application starts is saved to its own file by Save, asking nothing.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a project opened at start is saved as though it had no file", + ) + def test_save_writes_its_file(self, screen: Screen) -> None: + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(LINE_NUMBER) + screen.expect( + screen.title, titled(screen, marked(ARRANGED_PROJECT.stem, unsaved=True)).__eq__, description="changed" + ) + + screen.press_shortcut(ShortcutId.SAVE_PROJECT) + + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests() == () + screen.expect(screen.project.saved_notice.is_shown, bool, description="the project saved notice") diff --git a/tests/screens/sequencer/test_tracker.py b/tests/screens/sequencer/test_tracker.py new file mode 100644 index 000000000..93a1a2bd5 --- /dev/null +++ b/tests/screens/sequencer/test_tracker.py @@ -0,0 +1,421 @@ +import operator +from functools import partial +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK, NOTE_BLANK, NOTE_OFF +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.items import read_label +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A +from tests.suite.screens.screen import Screen +from tests.suite.screens.views.tracker import tracker_cell, tracker_cell_theme +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_ROW, PAD_ROW + +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") +PIANO_C_UP: Final[int] = IMGUI_LETTER_A + ord("q") - ord("a") +TYPING_FRAMES: Final[int] = 10 +EMPTY_VOICE: Final[str] = ".." +LINE_NUMBER: Final[str] = "00" +BASS_NUMBER: Final[str] = "01" +PAD_NUMBER: Final[str] = "02" +NOTE_AT_OCTAVE_TWO: Final[str] = "C-2" +NOTE_AT_OCTAVE_THREE: Final[str] = "C-3" +NOTE_AT_OCTAVE_FOUR: Final[str] = "C-4" +HIGHER_OCTAVE: Final[int] = 3 +NO_VOICE_ROW: Final[int] = 2 +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None +SAMPLE_HEADER: Final[str] = "sequencer.tracker.label.column_sample" +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +SET_VOICE: Final[str] = "sequencer.tracker.label.context_set_voice" +CLEAR_SUBCOLUMN: Final[str] = "sequencer.tracker.label.context_clear_subcolumn" +SUBCOLUMN_LETTERS: Final[Tuple[Tuple[SubColumn, str], ...]] = ( + (SubColumn.VOICE, "n"), + (SubColumn.TRANSPOSE, "t"), + (SubColumn.VOLUME, "v"), +) +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) + + +def history_size(screen: Screen) -> int: + return len(screen.sequencer.history.lines()) + + +def on_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") + + +def play_a_note(screen: Screen, row: int, channel: ChannelName, key: int) -> None: + screen.sequencer.tracker.click(row, channel, SubColumn.TRANSPOSE) + screen.hand.press_key(key, modifiers=[]) + screen.frames(TYPING_FRAMES) + + +def type_into(screen: Screen, row: int, channel: Optional[ChannelName], subcolumn: SubColumn, text: str) -> None: + screen.sequencer.tracker.click(row, channel, subcolumn) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + +def leave_letting_the_project_go(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestNotesTypedPianoStyle: + """A note key writes the note at the octave in force into a pitch slot whose channel carries a voice. + + The noise column, a voice slot and a row carrying no voice take no note, and nothing reaches the + history for them. + """ + + def test_notes_land_where_a_voice_plays_and_nowhere_else(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + history = screen.sequencer.history + + def a_note_lands_at_the_octave_in_force(screen: Screen) -> None: + on_the_sequencer(screen) + assert tracker.octave() == 2 + before = history_size(screen) + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_TWO + assert tracker.has_caret(PAD_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + 1 + assert history.current().words.endswith(f"t {NOTE_AT_OCTAVE_TWO}") + + def a_new_octave_lands_an_octave_up(screen: Screen) -> None: + tracker.raise_octave() + screen.expect(tracker.octave, HIGHER_OCTAVE.__eq__, description="the octave raised") + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C_UP) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_FOUR + + def the_noise_column_takes_no_note(screen: Screen) -> None: + type_into(screen, NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE, PAD_NUMBER) + assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE) == PAD_NUMBER + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.NOISE, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.TRANSPOSE) == NOTE_BLANK + assert history_size(screen) == before + + def the_voice_slot_takes_no_note(screen: Screen) -> None: + before = history_size(screen) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) == PAD_NUMBER + assert history_size(screen) == before + + def a_row_carrying_no_voice_records_nothing(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_BLANK + assert tracker.has_caret(NO_VOICE_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + + def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, PAD_ROW + 1, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + assert history_size(screen) == before + 1 + + screen.scenario( + a_note_lands_at_the_octave_in_force, + a_new_octave_lands_an_octave_up, + the_noise_column_takes_no_note, + the_voice_slot_takes_no_note, + a_row_carrying_no_voice_records_nothing, + a_note_where_the_voice_plays_on_lands, + leave_letting_the_project_go, + ).run() + + +class TestAVoiceTyped: + """A voice number typed into a slot arrives with its kind's color in the same frame. + + An instrument typed into the Sample column leaves both the number and the color as they were. + """ + + def test_the_color_comes_with_the_number(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + typed_row = 9 + + def cell_in_frame(row: int, channel: Optional[ChannelName]) -> Tuple[str, Optional[int]]: + return read_label(tracker_cell(row, channel, SubColumn.VOICE)), tracker_cell_theme( + row, channel, SubColumn.VOICE + ) + + def frames_while_typing(row: int, channel: Optional[ChannelName], text: str) -> List[Tuple[str, Optional[int]]]: + tracker.click(row, channel, SubColumn.VOICE) + with screen.record(partial(cell_in_frame, row, channel)) as recording: + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + return recording.values() + + def an_instrument_arrives_in_its_color(screen: Screen) -> None: + on_the_sequencer(screen) + instrument = tracker.theme(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + frames = frames_while_typing(typed_row, ChannelName.PULSE1, PAD_NUMBER) + + numbered = [theme for label, theme in frames if label == PAD_NUMBER] + assert numbered + assert all(theme == instrument for theme in numbered) + + def a_sample_arrives_in_its_color(screen: Screen) -> None: + sample = screen.sequencer.tracker.theme(0, ChannelName.PULSE1, SubColumn.VOICE) + + frames = frames_while_typing(typed_row + 2, ChannelName.PULSE1, LINE_NUMBER) + + numbered = [theme for label, theme in frames if label == LINE_NUMBER] + assert numbered + assert all(theme == sample for theme in numbered) + + def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: + standing = tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE), tracker.theme( + typed_row, SAMPLE_COLUMN, SubColumn.VOICE + ) + before = history_size(screen) + + frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) + + assert all(frame[1] == standing[1] for frame in frames) + assert all(frame[0] != PAD_NUMBER for frame in frames) + assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] + assert history_size(screen) == before + + screen.scenario( + an_instrument_arrives_in_its_color, + a_sample_arrives_in_its_color, + an_instrument_in_the_sample_column_changes_nothing, + leave_letting_the_project_go, + ).run() + + +class TestTheSampleColumn: + """The Sample column reads Sample, offers samples alone, and reads a row by the samples its channels play.""" + + def test_its_header_its_menu_and_its_readings(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + menu = screen.context_menu + cut_row = 6 + instrument_row = 2 + + def the_header_reads_sample(screen: Screen) -> None: + on_the_sequencer(screen) + + assert tracker.header(SAMPLE_COLUMN) == screen.words(SAMPLE_HEADER) + + def set_voice_offers_samples_alone(screen: Screen) -> None: + tracker.right_click(1, SAMPLE_COLUMN, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert [enabled for _, enabled in offered] == [True, True, False] + + def a_channel_cell_offers_every_voice(screen: Screen) -> None: + tracker.right_click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert len(offered) == len(screen.sequencer.voices.names()) + assert all(enabled for _, enabled in offered) + + def an_instrument_alone_reads_empty(screen: Screen) -> None: + type_into(screen, instrument_row, ChannelName.PULSE1, SubColumn.VOICE, PAD_NUMBER) + + assert tracker.label(instrument_row, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER + assert tracker.label(instrument_row, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + + def a_row_cut_everywhere_reads_the_cut(screen: Screen) -> None: + for channel in EVERY_CHANNEL: + type_into(screen, cut_row, channel, SubColumn.VOICE, "-") + + assert all(tracker.label(cut_row, channel, SubColumn.VOICE) == NOTE_OFF for channel in EVERY_CHANNEL) + assert tracker.label(cut_row, SAMPLE_COLUMN, SubColumn.VOICE) == NOTE_OFF + + def the_bass_alone_reads_as_itself(screen: Screen) -> None: + assert screen.sequencer.tracker.label(BASS_ROW, SAMPLE_COLUMN, SubColumn.VOICE) == BASS_NUMBER + + screen.scenario( + the_header_reads_sample, + set_voice_offers_samples_alone, + a_channel_cell_offers_every_voice, + an_instrument_alone_reads_empty, + a_row_cut_everywhere_reads_the_cut, + the_bass_alone_reads_as_itself, + leave_letting_the_project_go, + ).run() + + def test_a_volume_lands_where_the_sample_still_plays(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + placed_row = 1 + + def place_the_line_across_its_channels(screen: Screen) -> None: + on_the_sequencer(screen) + + type_into(screen, placed_row, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) + + assert [tracker.label(placed_row, channel, SubColumn.VOICE) for channel in LINE_CHANNELS] == [ + LINE_NUMBER + ] * 3 + assert tracker.label(placed_row, SAMPLE_COLUMN, SubColumn.VOICE) == LINE_NUMBER + + def a_volume_reaches_every_channel_it_plays(screen: Screen) -> None: + type_into(screen, placed_row + 1, SAMPLE_COLUMN, SubColumn.VOLUME, "8") + + assert {channel: tracker.label(placed_row + 1, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "8", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "8", + ChannelName.NOISE: "8", + } + + def a_channel_cut_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 2, ChannelName.NOISE, SubColumn.VOICE, "-") + + type_into(screen, placed_row + 3, SAMPLE_COLUMN, SubColumn.VOLUME, "9") + + assert {channel: tracker.label(placed_row + 3, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "9", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "9", + ChannelName.NOISE: BLANK, + } + + def a_channel_given_an_instrument_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 4, ChannelName.TRIANGLE, SubColumn.VOICE, PAD_NUMBER) + + type_into(screen, placed_row + 5, SAMPLE_COLUMN, SubColumn.VOLUME, "A") + + assert {channel: tracker.label(placed_row + 5, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "A", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: BLANK, + ChannelName.NOISE: BLANK, + } + + screen.scenario( + place_the_line_across_its_channels, + a_volume_reaches_every_channel_it_plays, + a_channel_cut_stops_taking_it, + a_channel_given_an_instrument_stops_taking_it, + leave_letting_the_project_go, + ).run() + + +class TestABlockPastedOnTheSampleColumn: + """A block holding an instrument pasted onto the Sample column lands everything but the instrument's cells.""" + + def test_the_instrument_is_passed_over(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + anchor = 9 + + def copy_an_instrument_and_a_sample(screen: Screen) -> None: + on_the_sequencer(screen) + type_into(screen, PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE, LINE_NUMBER) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + tracker.shift_click(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_COPY_BLOCK) + + def paste_it_on_the_sample_column(screen: Screen) -> None: + tracker.click(anchor, SAMPLE_COLUMN, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PASTE_BLOCK) + + screen.expect( + partial(tracker.label, anchor + 1, SAMPLE_COLUMN, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the sample landed", + ) + assert tracker.label(anchor, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + assert all(tracker.label(anchor, channel, SubColumn.VOICE) != PAD_NUMBER for channel in EVERY_CHANNEL) + + screen.scenario( + copy_an_instrument_and_a_sample, paste_it_on_the_sample_column, leave_letting_the_project_go + ).run() + + +class TestHistoryLinesNameTheSlot: + """A history line clearing a slot names it n for the voice, t for the transpose and v for the volume.""" + + def test_each_slot_has_its_letter(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + history = screen.sequencer.history + menu = screen.context_menu + letters: List[str] = [] + + def clear_each_slot_of_the_pad(screen: Screen) -> None: + on_the_sequencer(screen) + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.VOLUME, "9") + + for subcolumn, _ in reversed(SUBCOLUMN_LETTERS): + tracker.right_click(PAD_ROW, ChannelName.PULSE2, subcolumn) + screen.expect(menu.is_shown, bool, description="the cell's menu") + menu.choose(screen.words(CLEAR_SUBCOLUMN)) + screen.expect(menu.is_shown, operator.not_, description="the menu answered") + screen.frames(TYPING_FRAMES) + letters.append(history.current().words) + + for line, (_, letter) in zip(letters, reversed(SUBCOLUMN_LETTERS)): + assert letter in line.split() + + assert len(set(letters)) == len(SUBCOLUMN_LETTERS) + + screen.scenario(clear_each_slot_of_the_pad, leave_letting_the_project_go).run() + + +class TestPlayingFromATrackerRow: + """Playing the song from the caret's row reads as playing in the Playback menu, Stop answering.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: playing from a tracker row leaves the Playback menu reading Play", + ) + def test_the_menu_follows(self, screen: Screen) -> None: + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) + + screen.expect(screen.sequencer.playback.can_stop, bool, description="Stop answering") + assert screen.sequencer.playback.play_entry() == screen.words(PAUSE) diff --git a/tests/screens/sequencer/test_voices.py b/tests/screens/sequencer/test_voices.py new file mode 100644 index 000000000..276868030 --- /dev/null +++ b/tests/screens/sequencer/test_voices.py @@ -0,0 +1,274 @@ +import operator +import re +from functools import partial +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.items import Item, enclosing_regions, read_region_view +from tests.suite.screens.keyboard import press_combination +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import BY_CONFIGURATION +from tests.suite.screens.views.menus import MenuEntry +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD, SHORT_RECONSTRUCTION + +POOL_ACTIONS: Final[Tuple[str, ...]] = ( + "sequencer.voices.label.new_instrument", + "sequencer.voices.label.add_sample", + "sequencer.voices.label.import_instrument", +) +PLAY_VOICE: Final[str] = "global.context.label.play" +STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample" +STATUS_SEPARATOR: Final[str] = "sequencer.voices.template.status_channel_separator" +LINE_CHANNEL_NAMES: Final[Tuple[str, ...]] = ( + "global.context.label.pulse_1", + "global.context.label.triangle", + "global.context.label.noise", +) +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +EMPTY_SPACE_INSET: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 20 +MOVE_DOWN_KEYS: Final[str] = "Alt+Down" +PLACED_ROW: Final[int] = 1 +ADDED_NUMBER: Final[str] = "03" +HOVER_RACE: Final[str] = "Error executing callback _on_row_hovered" + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def voice_row(screen: Screen, name: str) -> Item: + screen.tabs.bring_to_front(Tab.SEQUENCER) + return screen.expect_item(partial(screen.sequencer.voices.row, name), description=f"the row of {name}") + + +def right_click_the_empty_list(screen: Screen) -> None: + """Presses the right button over the list below its rows.""" + view = screen.bridge.ask(lambda: read_region_view(enclosing_regions(TAG_SEQUENCER_VOICES_TABLE)[0])) + assert view is not None + screen.hand.right_click_at( + Point(x=round(view.x + view.width / 2), y=round(view.y + view.height - EMPTY_SPACE_INSET)) + ) + + +def voice_actions(entries: List[str], pool: Tuple[str, ...]) -> List[str]: + """The entries acting on the voice picked, which leaves out the pool's own.""" + return [entry for entry in entries if entry not in pool] + + +def menu_on_the_bar(screen: Screen, group: MenuElements) -> List[MenuEntry]: + """Opens the menu ``group`` with a click, reads what it offers once it settles, and puts it away.""" + screen.menu.open(group) + screen.frames(SETTLING_FRAMES) + entries = screen.menu.entries(group) + screen.menu.close() + screen.frames(SETTLING_FRAMES) + return entries + + +def forgive_the_hover_race(screen: Screen) -> None: + """Forgives the error a voice row's hover meets once the list has rebuilt the row, which the ledger records.""" + screen.forgive_known_error(HOVER_RACE) + + +def leave_letting_the_project_go(screen: Screen) -> None: + forgive_the_hover_race(screen) + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestTheVoicesMenus: + """A voice row's menu offers the voice's actions and the pool's; the empty list offers the pool's alone. + + The Voice and Edit menus name the voice's actions the row's menu names, Play aside, and the keys + they print are the keys that fire them. + """ + + def test_menus_agree_and_their_keys_fire(self, screen: Screen) -> None: + voices = screen.sequencer.voices + menu = screen.context_menu + pool = tuple(screen.words(key) for key in POOL_ACTIONS) + row_actions: List[str] = [] + + def the_row_offers_both(screen: Screen) -> None: + voices.right_click(voice_row(screen, LINE)) + screen.expect(menu.is_shown, bool, description="the row's menu") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + assert tuple(labels[-len(pool) :]) == pool + row_actions.extend(voice_actions(labels, pool)) + + def the_empty_list_offers_the_pool_alone(screen: Screen) -> None: + right_click_the_empty_list(screen) + + screen.expect(menu.is_shown, bool, description="the list's menu") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert tuple(labels) == pool + + def the_bar_menus_name_the_same_actions(screen: Screen) -> None: + voices.pick(voice_row(screen, LINE)) + screen.expect(partial(voices.is_picked, 0), bool, description="the line picked") + + voice_menu = menu_on_the_bar(screen, MenuElements.GROUP_VOICE) + edit_menu = menu_on_the_bar(screen, MenuElements.GROUP_EDIT) + + from_voice = [entry for entry in voice_menu if entry.label in row_actions] + from_edit = [entry for entry in edit_menu if entry.label in row_actions] + assert [entry.label for entry in from_voice] == [entry.label for entry in from_edit] + assert [entry.keys for entry in from_voice] == [entry.keys for entry in from_edit] + assert set(row_actions) - {entry.label for entry in from_voice} == {screen.words(PLAY_VOICE)} + + def the_printed_keys_fire(screen: Screen) -> None: + moves = [ + entry for entry in menu_on_the_bar(screen, MenuElements.GROUP_VOICE) if entry.keys == MOVE_DOWN_KEYS + ] + assert moves + + press_combination(screen.hand, KeyCombination.parse(moves[0].keys)) + + screen.expect(voices.names, [BASS_VOICE, LINE, PAD].__eq__, description="the line moved down") + + screen.scenario( + the_row_offers_both, + the_empty_list_offers_the_pool_alone, + the_bar_menus_name_the_same_actions, + the_printed_keys_fire, + leave_letting_the_project_go, + ).run() + + +class TestHoveringAVoice: + """The status line says what the voice under the pointer is: its name, its kind and the channels it plays.""" + + def test_the_status_names_the_sample(self, screen: Screen) -> None: + row = voice_row(screen, LINE) + separator = screen.words(STATUS_SEPARATOR) + channels = separator.join(screen.words(key) for key in LINE_CHANNEL_NAMES) + template = screen.words(STATUS_SAMPLE).format(name=LINE, channels=channels, bytes="{bytes}") + expected = re.compile(re.escape(template).replace(re.escape("{bytes}"), ".+")) + + screen.hand.move_to(Point(x=0, y=0)) + screen.hand.hover(row) + + screen.expect(screen.status, lambda status: expected.fullmatch(status) is not None, description="the status") + forgive_the_hover_race(screen) + + +class TestTheKeysOfACollapsedVoicesCard: + """With the Voices card collapsed, the keys of the voice picked change nothing; open, they act.""" + + def test_keys_wait_for_the_card(self, screen: Screen) -> None: + voices = screen.sequencer.voices + + def pick_the_line_and_collapse_the_card(screen: Screen) -> None: + voices.pick(voice_row(screen, LINE)) + screen.expect(partial(voices.is_picked, 0), bool, description="the line picked") + + voices.card.toggle() + + screen.expect(voices.card.is_collapsed, bool, description="the card collapsed") + + def its_keys_change_nothing(screen: Screen) -> None: + press_combination(screen.hand, KeyCombination.parse(MOVE_DOWN_KEYS)) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [LINE, BASS_VOICE, PAD] + assert screen.shown_windows() == () + + def open_they_act(screen: Screen) -> None: + voices.card.toggle() + screen.expect(voices.card.is_collapsed, operator.not_, description="the card open") + voices.pick(voice_row(screen, LINE)) + + press_combination(screen.hand, KeyCombination.parse(MOVE_DOWN_KEYS)) + + screen.expect(voices.names, [BASS_VOICE, LINE, PAD].__eq__, description="the line moved down") + + screen.scenario( + pick_the_line_and_collapse_the_card, + its_keys_change_nothing, + open_they_act, + leave_letting_the_project_go, + ).run() + + +class TestNewInstrumentWithNoProjectOpen: + """New instrument with no project open adds nothing, and leaving asks nothing.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: New instrument with no project open writes into a project nobody opened", + ) + def test_nothing_is_added(self, screen: Screen) -> None: + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.project.close() + screen.expect(voices.names, operator.not_, description="no voices listed") + + voices.new_instrument() + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [] + screen.press_shortcut(ShortcutId.EXIT) + assert screen.wait_for_exit() + + +class TestASampleAddedFromTheBrowser: + """A reconstruction double-clicked in the Sequencer's browser joins the voices; placed, the song plays it.""" + + def test_it_joins_and_plays(self, screen: Screen) -> None: + voices = screen.sequencer.voices + browser = screen.sequencer.browser + tracker = screen.sequencer.tracker + + def double_click_it_in_the_browser(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + heading = screen.expect_item( + partial(browser.heading, screen.words(BY_CONFIGURATION)), description="the heading" + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + row = screen.expect_item(partial(browser.file_row, SHORT_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect(lambda: len(voices.names()), (4).__eq__, description="a fourth voice") + assert voices.names()[:3] == [LINE, BASS_VOICE, PAD] + + def placed_the_song_plays_it(screen: Screen) -> None: + tracker.click(PLACED_ROW, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(ADDED_NUMBER) + screen.expect( + partial(tracker.label, PLACED_ROW, ChannelName.PULSE2, SubColumn.VOICE), + ADDED_NUMBER.__eq__, + description="the sample placed", + ) + + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + screen.press_shortcut(ShortcutId.STOP) + + screen.scenario(double_click_it_in_the_browser, placed_the_song_plays_it, leave_letting_the_project_go).run() diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index 4ae6644d4..e78ade58f 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -142,15 +142,19 @@ def click_at(self, point: Point) -> None: The press confirms it reached the application, while what it landed on is for the scenario to read from what the press changed. """ - self._press_at(point, SINGLE_PRESS) + self._press_at(point, MouseButton.LEFT, SINGLE_PRESS) def double_click_at(self, point: Point) -> None: """Presses the left button twice in quick succession at ``point`` of the viewport.""" - self._press_at(point, DOUBLE_PRESS) + self._press_at(point, MouseButton.LEFT, DOUBLE_PRESS) + + def right_click_at(self, point: Point) -> None: + """Presses the right button at ``point`` of the viewport, for a menu a place offers rather than an item.""" + self._press_at(point, MouseButton.RIGHT, SINGLE_PRESS) def triple_click_at(self, point: Point) -> None: """Presses the left button three times in quick succession at ``point`` of the viewport.""" - self._press_at(point, TRIPLE_PRESS) + self._press_at(point, MouseButton.LEFT, TRIPLE_PRESS) def move_to(self, point: Point) -> None: """Rests the pointer at ``point`` of the viewport.""" @@ -393,11 +397,16 @@ def _application_seconds(self) -> Optional[float]: except RenderThreadStoppedError: return None - def _press_at(self, point: Point, count: int) -> None: + def _press_at( + self, + point: Point, + button: MouseButton, + count: int, + ) -> None: self.move_to(point) self._outlast_a_double_click() for _ in range(count): - self._press_once(MouseButton.LEFT, HOLD_FRAMES) + self._press_once(button, HOLD_FRAMES) self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) diff --git a/tests/suite/screens/dearpygui/plots.py b/tests/suite/screens/dearpygui/plots.py new file mode 100644 index 000000000..7a5ae21a1 --- /dev/null +++ b/tests/suite/screens/dearpygui/plots.py @@ -0,0 +1,47 @@ +from functools import partial +from typing import Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge + +PlotValue = Tuple[float, float] + + +def read_plot_pointer() -> PlotValue: + """The value the plot under the pointer puts there, as its last drawn frame read it.""" + x, y = dpg.get_plot_mouse_pos() + return float(x), float(y) + + +def settled_plot_pointer( + bridge: Bridge, + plot: str, + *, + timeout: float, +) -> PlotValue: + """The value ``plot`` puts under the resting pointer, once the plot holds it for two frames running. + + The plot reads the pointer as it draws, so its value trails the pointer by a frame. + + Raises: + ExpectationError: If the pointer never rests on ``plot`` or its value keeps changing. + """ + bridge.expect( + lambda: bool(dpg.is_item_hovered(plot)), + bool, + description=f"the pointer resting on '{plot}'", + timeout=timeout, + ) + return bridge.expect( + partial(_two_frames_of_pointer, bridge), + lambda readings: readings[0] == readings[1], + description=f"the value under the pointer on '{plot}' holding still", + timeout=timeout, + )[1] + + +def _two_frames_of_pointer(bridge: Bridge) -> Tuple[PlotValue, PlotValue]: + first = bridge.ask(read_plot_pointer) + bridge.frames(ONE_FRAME) + return first, bridge.ask(read_plot_pointer) diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 5afb0175d..2926b051f 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -40,6 +40,7 @@ from tests.suite.screens.dearpygui.windows import WindowManager from tests.suite.screens.keyboard import press_combination, primary_combination from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.views.audio_settings import AudioSettings from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.context_menu import ContextMenu from tests.suite.screens.views.display_settings import DisplaySettings @@ -95,9 +96,10 @@ def __init__( self._artifacts = artifacts self._language = language self.tabs = Tabs(bridge, hand) - self.menu = MenuBar(bridge, language) + self.menu = MenuBar(bridge, hand, language) self.display_settings = DisplaySettings(bridge, hand, self.menu) self.keyboard_settings = KeyboardSettings(bridge, hand, self.menu) + self.audio_settings = AudioSettings(bridge, hand) self.project = Project(bridge, hand, self.menu) self.main = Main(bridge, hand, language) self.context_menu = ContextMenu(bridge, hand) @@ -241,6 +243,15 @@ def claim_error(self, naming: str) -> None: description=f"an error naming '{naming}'", ) + def forgive_known_error(self, naming: str) -> None: + """Claims every error holding ``naming``, which a defect the bugs ledger records provokes by chance. + + A scenario about something else stays quiet about a defect its gestures may meet, while the + scenario reproducing the defect holds it to account. + """ + while self._errors.claim(naming): + continue + def press_shortcut(self, shortcut_id: ShortcutId) -> None: """Presses the keys the scheme in place gives ``shortcut_id`` on the real keyboard.""" press_combination(self.hand, primary_combination(self._shortcuts.shortcut(shortcut_id))) diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py index db8785dea..6c8e14107 100644 --- a/tests/suite/screens/seeds.py +++ b/tests/suite/screens/seeds.py @@ -10,16 +10,19 @@ from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.fft import Window from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.project import ProjectContainer from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document from tests.suite.library import build_served_library +from tests.suite.performance import place_instrument from tests.suite.stems import recorded_from, single_entry_stems_data FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" @@ -40,6 +43,9 @@ BEAT_LENGTH: Final[int] = 3 BEAT_PERIOD: Final[int] = 4 BEAT_VOLUME: Final[int] = 10 +ARRANGED_FRAMES: Final[int] = 30 +PAD_VOLUME: Final[Tuple[int, ...]] = (12, 8, 4) +PAD_ARPEGGIO: Final[Tuple[int, ...]] = (0, 0, 0) class Damage(StrEnum): @@ -186,6 +192,57 @@ def _playable_instructions(frames: int) -> Dict[ChannelName, List[InstructionUni return {ChannelName.PULSE1: line, ChannelName.TRIANGLE: bass, ChannelName.NOISE: beat} +@dataclass(frozen=True) +class ArrangedProject: + """A project with two samples and a hand-written voice, each placed once on the song's first pattern. + + ``line`` is a sample sounding Pulse 1, the triangle and the noise; ``bass`` a sample sounding the + triangle alone; ``pad`` a hand-written voice fading on Pulse 2, its arpeggio flat. The first starts the pattern on + Pulse 1, the hand-written voice comes in on Pulse 2 at ``pad_row``, and the bass on the triangle at + ``bass_row``. + """ + + destination: Path + line: str + bass: str + pad: str + pad_row: int + bass_row: int + + def write(self) -> None: + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions)) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions)) + pad = Instrument( + name=self.pad, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=PAD_VOLUME), + arpeggio=Envelope[int](items=PAD_ARPEGGIO), + ), + ) + for voice in (line, bass, pad): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=line) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=self.pad_row, sample=pad) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=self.bass_row, sample=bass) + self.destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, self.destination) + + +def _detached_reconstruction(instructions: Dict[ChannelName, List[InstructionUnion]]) -> Reconstruction: + """A reconstruction of ``instructions`` naming no recording, the way a project stores a sample.""" + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=(), + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + + def stored_recording() -> Recording: """The recording the archived reconstruction names, laid where its relative path leads from the home. diff --git a/tests/suite/screens/views/audio_settings.py b/tests/suite/screens/views/audio_settings.py new file mode 100644 index 000000000..3042d634e --- /dev/null +++ b/tests/suite/screens/views/audio_settings.py @@ -0,0 +1,60 @@ +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN, + TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB, + TAG_SETTINGS_AUDIO_WINDOW, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.dearpygui.reach import UnreachableError + +COLOR: Final[str] = "color" +MIDDLE: Final[float] = 0.5 + + +class AudioSettings: + """The Audio settings dialog, as far as its master gain: the slider and the line reading its level in decibels.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_WINDOW)).shown + + def gain(self) -> float: + return float(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN))) + + def decibels(self) -> str: + """What the line under the slider reads.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB))) + + def decibels_color(self) -> Tuple[float, ...]: + """The color the line under the slider is drawn in.""" + return tuple( + float(part) + for part in self._bridge.ask( + lambda: dpg.get_item_configuration(TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB)[COLOR] + ) + ) + + def drag_gain(self, start: float, end: float) -> None: + """Drags the slider from ``start`` to ``end``, each a fraction of its width, past its ends where beyond them.""" + box = self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN).rect) + if box is None: + raise UnreachableError("The master gain slider reports no box") + + middle = round(box.y + box.height * MIDDLE) + self._hand.drag( + Point(x=round(box.x + box.width * start), y=middle), + Point(x=round(box.x + box.width * end), y=middle), + ) diff --git a/tests/suite/screens/views/bar_graph.py b/tests/suite/screens/views/bar_graph.py index b42d03999..a2ab56325 100644 --- a/tests/suite/screens/views/bar_graph.py +++ b/tests/suite/screens/views/bar_graph.py @@ -1,14 +1,13 @@ from dataclasses import dataclass from typing import Final, List, Tuple -import dearpygui.dearpygui as dpg - from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.graphs import SUF_GRAPH_PLOT from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.plots import settled_plot_pointer NEAR: Final[float] = 0.25 FAR: Final[float] = 0.75 @@ -72,14 +71,7 @@ def mapping(self) -> PlotMapping: def value_under(self, point: Point) -> Tuple[float, float]: """Rests the pointer at ``point`` and reads the value the plot puts under it.""" self._hand.move_to(point) - self._bridge.expect( - lambda: bool(dpg.is_item_hovered(self.plot)), - bool, - description=f"the pointer resting on '{self.plot}'", - timeout=HOVER_SECONDS, - ) - x, y = self._bridge.ask(dpg.get_plot_mouse_pos) - return float(x), float(y) + return settled_plot_pointer(self._bridge, self.plot, timeout=HOVER_SECONDS) def zoom_in(self, fraction: float, notches: int) -> None: """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, widening each bar there.""" diff --git a/tests/suite/screens/views/context_menu.py b/tests/suite/screens/views/context_menu.py index fb972004e..5360d3fb3 100644 --- a/tests/suite/screens/views/context_menu.py +++ b/tests/suite/screens/views/context_menu.py @@ -1,13 +1,23 @@ -from typing import Final, List +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg from sampletones_application.tags.general import TAG_GLOBAL_CONTEXT_WINDOW, TAG_GLOBAL_TAB_INSTRUCTIONS from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point, Rect from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import EntryReading, read_item, read_popup_entries +from tests.suite.screens.dearpygui.items import ( + MENU_ITEM_TYPE, + MENU_TYPE, + EntryReading, + read_item, + read_label, + read_popup_entries, +) ENTRY_INSET: Final[Point] = Point(x=12, y=8) CLEAR_OF_THE_TABS: Final[int] = 48 +ENABLED: Final[str] = "enabled" class MissingEntryError(AssertionError): @@ -47,6 +57,30 @@ def entries(self) -> List[EntryReading]: def labels(self) -> List[str]: return [entry.label for entry in self.entries()] + def submenu(self, label: str) -> List[Tuple[str, bool]]: + """The entries of the submenu reading ``label``, each with whether it answers. + + Raises: + MissingEntryError: If the menu offers no such submenu. + """ + + def read() -> Optional[List[Tuple[str, bool]]]: + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1): + if dpg.get_item_info(child)["type"] == MENU_TYPE and read_label(child) == label: + return [ + (read_label(entry), bool(dpg.get_item_configuration(entry).get(ENABLED, True))) + for entry in dpg.get_item_children(child, 1) + if dpg.get_item_info(entry)["type"] == MENU_ITEM_TYPE + ] + + return None + + found = self._bridge.ask(read) + if found is None: + raise MissingEntryError(f"The context menu offers no submenu '{label}'") + + return found + def choose(self, label: str) -> None: """Clicks the entry reading ``label``. diff --git a/tests/suite/screens/views/history.py b/tests/suite/screens/views/history.py new file mode 100644 index 000000000..2ee3b0290 --- /dev/null +++ b/tests/suite/screens/views/history.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_HISTORY_BUTTON_REDO, + TAG_SEQUENCER_HISTORY_BUTTON_UNDO, + TAG_SEQUENCER_HISTORY_WINDOW_LIST, +) +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import TEXT_TYPE, Item + +TABLE_TYPE: Final[str] = "mvAppItemType::mvTable" +ENTRY_GROUP: Final[int] = 1 +COLOR_KEY: Final[str] = "color" + + +@dataclass(frozen=True) +class HistorySegment: + """One piece of a history line: its words and the color they are drawn in.""" + + words: str + color: Tuple[float, ...] + + +@dataclass(frozen=True) +class HistoryLine: + """One entry of the History card as it reads: its pieces, and whether it is the state in force.""" + + segments: Tuple[HistorySegment, ...] + current: bool + + @property + def words(self) -> str: + return " ".join(segment.words for segment in self.segments) + + +class History: + """The History card of the Sequencer: one line per entry, the newest on top, with Undo and Redo below.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def lines(self) -> Tuple[HistoryLine, ...]: + """The lines the card draws, newest first.""" + return self._bridge.ask(read_history) + + def current(self) -> HistoryLine: + """The line of the entry the project stands at.""" + return next(line for line in self.lines() if line.current) + + def undo(self) -> None: + self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_UNDO, SUF_BUTTON)) + + def redo(self) -> None: + self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_REDO, SUF_BUTTON)) + + +def read_history() -> Tuple[HistoryLine, ...]: + """The lines the History card draws, newest first. Runs on the render thread.""" + tables = [ + child + for child in dpg.get_item_children(TAG_SEQUENCER_HISTORY_WINDOW_LIST, 1) + if dpg.get_item_info(child)["type"] == TABLE_TYPE + ] + if not tables: + return () + + lines: List[HistoryLine] = [] + for row in dpg.get_item_children(tables[0], 1): + selectable, group = dpg.get_item_children(row, 1)[: ENTRY_GROUP + 1] + lines.append(HistoryLine(segments=_segments(group), current=bool(dpg.get_value(selectable)))) + + return tuple(lines) + + +def _segments(group: Item) -> Tuple[HistorySegment, ...]: + return tuple( + HistorySegment( + words=str(dpg.get_value(text)), + color=tuple(float(part) for part in dpg.get_item_configuration(text)[COLOR_KEY]), + ) + for text in dpg.get_item_children(group, 1) + if dpg.get_item_info(text)["type"] == TEXT_TYPE + ) diff --git a/tests/suite/screens/views/menus.py b/tests/suite/screens/views/menus.py index 1526d9b37..5992e3b31 100644 --- a/tests/suite/screens/views/menus.py +++ b/tests/suite/screens/views/menus.py @@ -1,12 +1,42 @@ +from dataclasses import dataclass +from typing import Final, List + +import dearpygui.dearpygui as dpg + from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.tags.general import TAG_GLOBAL_WINDOW_MAIN +from sampletones_application.tags.general import TAG_GLOBAL_TAB_INSTRUCTIONS, TAG_GLOBAL_WINDOW_MAIN from sampletones_application.utils.callbacks.queue import CallbackQueue from tests.suite.screens.dearpygui.bridge import Bridge -from tests.suite.screens.dearpygui.items import MENU_ITEM_TYPE, MENU_TYPE, Item, find_labelled, read_item +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import ( + MENU_ITEM_TYPE, + MENU_TYPE, + Item, + find_labelled, + read_item, + read_label, + read_viewport, +) from tests.suite.screens.dearpygui.semantic import invoke +HEADER_INSET: Final[Point] = Point(x=12, y=8) +ENABLED: Final[str] = "enabled" +SHORTCUT: Final[str] = "shortcut" +GROUP_TYPE: Final[str] = "mvAppItemType::mvGroup" +FAR_END_INSET: Final[int] = 30 + + +@dataclass(frozen=True) +class MenuEntry: + """One entry of a menu on the bar: its words, whether it answers, and the keys printed beside it.""" + + label: str + enabled: bool + keys: str + class MenuBar: """The menu bar along the top of the window, read by the words its menus show. @@ -19,9 +49,11 @@ class MenuBar: def __init__( self, bridge: Bridge, + hand: Hand, language: LanguageManager, ) -> None: self._bridge = bridge + self._hand = hand self._language = language def choose( @@ -32,6 +64,35 @@ def choose( """Chooses the entry ``item`` of the menu ``group``.""" self._bridge.ask(lambda: invoke(self._entry(group, item), CallbackQueue.run)) + def open(self, group: MenuElements) -> None: + """Clicks the header of the menu ``group``, which opens its popup.""" + corner = self._bridge.ask(lambda: dpg.get_item_state(self._menu(group))["pos"]) + self._hand.click_at(Point(x=round(corner[0]) + HEADER_INSET.x, y=round(corner[1]) + HEADER_INSET.y)) + + def close(self) -> None: + """Clicks the bare far end of the tab bar, clear of every menu's popup, which puts an open menu away.""" + last_tab = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS).rect) + viewport = self._bridge.ask(read_viewport) + if last_tab is None: + raise LookupError("The last tab reports no box to click beside") + + self._hand.click_at(Point(x=round(viewport.width) - FAR_END_INSET, y=last_tab.center.y)) + + def entries(self, group: MenuElements) -> List[MenuEntry]: + """The entries of the menu ``group``, in order: their words, whether they answer, and the keys printed.""" + + def read() -> List[MenuEntry]: + return [ + MenuEntry( + label=read_label(entry), + enabled=bool(dpg.get_item_configuration(entry).get(ENABLED, True)), + keys=str(dpg.get_item_configuration(entry).get(SHORTCUT, "")), + ) + for entry in _menu_items(self._menu(group)) + ] + + return self._bridge.ask(read) + def is_enabled( self, group: MenuElements, @@ -40,6 +101,10 @@ def is_enabled( """Whether the entry ``item`` of the menu ``group`` answers a press.""" return self._bridge.ask(lambda: read_item(self._entry(group, item)).enabled) + def _menu(self, group: MenuElements) -> Item: + """The menu ``group`` on the bar. Runs on the render thread.""" + return find_labelled(TAG_GLOBAL_WINDOW_MAIN, self._label(group), item_type=MENU_TYPE) + def _entry( self, group: MenuElements, @@ -64,3 +129,16 @@ def _label(self, element: MenuElements) -> str: TextType.LABEL, element, ] + + +def _menu_items(menu: Item) -> List[Item]: + """The entries under ``menu`` in the order drawn, those of its groups among them. Runs on the render thread.""" + found: List[Item] = [] + for child in dpg.get_item_children(menu, 1): + kind = dpg.get_item_info(child)["type"] + if kind == MENU_ITEM_TYPE: + found.append(child) + elif kind == GROUP_TYPE: + found.extend(_menu_items(child)) + + return found diff --git a/tests/suite/screens/views/project.py b/tests/suite/screens/views/project.py index 893701971..6a7b77b86 100644 --- a/tests/suite/screens/views/project.py +++ b/tests/suite/screens/views/project.py @@ -8,6 +8,8 @@ ) from sampletones_application.tags.settings import ( TAG_SETTINGS_PROPERTIES_BUTTON_OK, + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, TAG_SETTINGS_PROPERTIES_INPUT_TITLE, TAG_SETTINGS_PROPERTIES_WINDOW, ) @@ -45,6 +47,11 @@ def retitle(self, title: str) -> None: """Types ``title`` over the title the field holds.""" self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, title) + def retype_highlights(self, first: int, second: int) -> None: + """Types ``first`` and ``second`` over the fields of the rows the tracker tints.""" + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, str(first)) + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, str(second)) + def confirm(self) -> None: self._hand.click(compose_tag(TAG_SETTINGS_PROPERTIES_BUTTON_OK, SUF_BUTTON)) diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py index 3f8216164..484a3a49a 100644 --- a/tests/suite/screens/views/sequencer.py +++ b/tests/suite/screens/views/sequencer.py @@ -1,15 +1,40 @@ -from typing import Final, List, Optional +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg from sampletones_application.categories.elements.global_ import MenuElements -from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP -from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_DIALOG_REMOVE, TAG_SEQUENCER_VOICES_TABLE +from sampletones_application.tags.general import ( + TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, + TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP, +) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_BROWSER_TREE, + TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, + TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, + TAG_SEQUENCER_ORDER_TABLE, + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_DIALOG_REMOVE, + TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TABLE, +) +from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_table +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.views.browsers import FileTree +from tests.suite.screens.views.history import History +from tests.suite.screens.views.main import Card from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.views.tracker import Tracker NAME_COLUMN: Final[int] = 2 +KIND_COLUMN: Final[int] = 0 +COLOR: Final[str] = "color" +MASTER_ORDER_ROW: Final[int] = 0 +FIRST_CHANNEL_ORDER_ROW: Final[int] = 2 +ORDER_LABEL_CELLS: Final[int] = 1 CELL_CONTENT: Final[int] = 0 @@ -24,6 +49,34 @@ def __init__( self._bridge = bridge self._hand = hand self.remove_prompt = Prompt(bridge, hand, TAG_SEQUENCER_VOICES_DIALOG_REMOVE) + self.card = Card(bridge, hand, TAG_SEQUENCER_VOICES_PANEL) + + def kind_color(self, name: str) -> Tuple[float, ...]: + """The color of the mark naming the kind of the voice ``name``.""" + + def read() -> Tuple[float, ...]: + row = next( + row + for row in read_table(TAG_SEQUENCER_VOICES_TABLE) + if read_label(row[NAME_COLUMN][CELL_CONTENT]) == name + ) + return tuple(float(part) for part in dpg.get_item_configuration(row[KIND_COLUMN][CELL_CONTENT])[COLOR]) + + return self._bridge.ask(read) + + def pick(self, row: Item) -> None: + """Clicks ``row``, which picks its voice.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def is_picked(self, position: int) -> bool: + """Whether the row at ``position`` stands picked.""" + return bool(self._bridge.ask(lambda: dpg.is_table_row_highlighted(TAG_SEQUENCER_VOICES_TABLE, position))) + + def new_instrument(self) -> None: + """Clicks New instrument above the list.""" + self._hand.scroll_into_view(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) + self._hand.click(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) def names(self) -> List[str]: """The names the rows show, top to bottom.""" @@ -76,8 +129,57 @@ def can_stop(self) -> bool: return self._bridge.ask(lambda: read_item(TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP)).enabled +class OrderTable: + """The order table: a row per channel under the Master row, a column per frame of the song.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def label(self, channel: Optional[ChannelName], position: int) -> str: + """What the entry of ``channel`` at frame ``position`` reads, the Master row's for ``None``.""" + return self._bridge.ask(lambda: read_label(_order_entry(channel, position))) + + def positions(self) -> int: + """How many frames the song orders.""" + return self._bridge.ask( + lambda: len(read_table(TAG_SEQUENCER_ORDER_TABLE)[MASTER_ORDER_ROW]) - ORDER_LABEL_CELLS + ) + + def click(self, channel: Optional[ChannelName], position: int) -> None: + entry = self._bridge.ask(lambda: _order_entry(channel, position)) + self._hand.scroll_into_view(entry) + self._hand.click(entry) + + +class ModuleOptions: + """The module options card: the song's NES frequency, rows, tempo and speed.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.retune_prompt = Prompt(bridge, hand, TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY) + + def nes_frequency(self) -> int: + return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY))) + + def retype_nes_frequency(self, frequency: int) -> None: + """Types ``frequency`` over the NES frequency field and presses Enter, which commits it.""" + self._hand.scroll_into_view(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY) + self._hand.replace_text(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, str(frequency)) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + class Sequencer: - """The Sequencer tab, as far as scenarios read it.""" + """The Sequencer tab: the tracker, the voices, the history, and song playback.""" def __init__( self, @@ -87,8 +189,20 @@ def __init__( ) -> None: self.voices = Voices(bridge, hand) self.playback = Playback(bridge, menu) + self.tracker = Tracker(bridge, hand) + self.history = History(bridge, hand) + self.browser = FileTree(bridge, hand, TAG_SEQUENCER_BROWSER_TREE) + self.order = OrderTable(bridge, hand) + self.module = ModuleOptions(bridge, hand) def _names() -> List[Item]: """The name cell of every voice row, top to bottom. Runs on the render thread.""" - return [row[NAME_COLUMN][CELL_CONTENT] for row in read_table(TAG_SEQUENCER_VOICES_TABLE)] + return [row[NAME_COLUMN][CELL_CONTENT] for row in read_table(TAG_SEQUENCER_VOICES_TABLE) if len(row) > NAME_COLUMN] + + +def _order_entry(channel: Optional[ChannelName], position: int) -> Item: + """The entry of ``channel`` at frame ``position`` in the order table. Runs on the render thread.""" + row = MASTER_ORDER_ROW if channel is None else FIRST_CHANNEL_ORDER_ROW + ChannelName.items().index(channel) + entry: Item = read_table(TAG_SEQUENCER_ORDER_TABLE)[row][ORDER_LABEL_CELLS + position][CELL_CONTENT] + return entry diff --git a/tests/suite/screens/views/tracker.py b/tests/suite/screens/views/tracker.py new file mode 100644 index 000000000..1b3ab459e --- /dev/null +++ b/tests/suite/screens/views/tracker.py @@ -0,0 +1,117 @@ +from typing import Final, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.sequencer import TAG_SEQUENCER_TRACKER_INPUT_OCTAVE, TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.panels.sequencer.columns import tracker_table_column, tracker_table_row +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import Item, read_item, read_label +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT +from tests.suite.screens.dearpygui.reach import UnreachableError + +HEADER_ROW: Final[int] = 0 +CELL_GROUP: Final[int] = 0 +HALF: Final[float] = 0.5 + + +def tracker_cell(row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Item: + """The selectable drawing one subcolumn of one channel's cell on a pattern row. Runs on the render thread. + + The Sample column stands for ``channel`` ``None``. + """ + table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[tracker_table_row(row)] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + group = dpg.get_item_children(cell, 1)[CELL_GROUP] + slot: Item = dpg.get_item_children(group, 1)[SUBCOLUMNS.index(subcolumn)] + return slot + + +def tracker_cell_theme(row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + """The theme the cell's text is drawn with, which cells of one kind and state share. Runs on the render thread.""" + theme = dpg.get_item_info(tracker_cell(row, channel, subcolumn))["theme"] + return int(theme) if theme is not None else None + + +class Tracker: + """The tracker grid: a row per pattern row, the Sample column and a column per channel, three slots to a cell. + + A cell's slots are the voice, the transpose and the volume, and a gesture names a slot by its + row, its channel, or ``None`` for the Sample column, and its subcolumn. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def header(self, channel: Optional[ChannelName]) -> str: + """What the header of ``channel``'s column reads, the Sample column's for ``None``.""" + + def read() -> str: + table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[HEADER_ROW] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + return read_label(dpg.get_item_children(cell, 1)[CELL_GROUP]) + + return self._bridge.ask(read) + + def label(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> str: + return self._bridge.ask(lambda: read_label(tracker_cell(row, channel, subcolumn))) + + def labels(self, row: int, channel: Optional[ChannelName]) -> Tuple[str, ...]: + """What the three slots of one cell read, voice first.""" + return tuple(self.label(row, channel, subcolumn) for subcolumn in SUBCOLUMNS) + + def theme(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + return self._bridge.ask(lambda: tracker_cell_theme(row, channel, subcolumn)) + + def click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot, which puts the caret there.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click(item) + + def shift_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot holding Shift, which stretches the block from the caret to it.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click_holding(item, [IMGUI_LEFT_SHIFT]) + + def right_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.right_click(item) + + def has_caret(self, row: int, channel: Optional[ChannelName]) -> bool: + """Whether the caret stands in ``channel``'s cell on ``row``.""" + return bool( + self._bridge.ask( + lambda: dpg.is_table_cell_highlighted( + TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row), tracker_table_column(channel) + ) + ) + ) + + def is_row_tinted(self, row: int) -> bool: + """Whether the row carries a background, as a beat, a bar, the caret's row or the playing row does.""" + return bool( + self._bridge.ask(lambda: dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row))) + ) + + def octave(self) -> int: + return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE))) + + def raise_octave(self) -> None: + """Clicks the plus at the right end of the octave field, a square as tall as the field.""" + box = self._bridge.ask(lambda: read_item(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE).rect) + if box is None: + raise UnreachableError("The octave field reports no box") + + self._hand.click_at(Point(x=round(box.x + box.width - box.height * HALF), y=round(box.y + box.height * HALF))) diff --git a/tests/suite/screens/views/waveform.py b/tests/suite/screens/views/waveform.py index 06274e0c0..09f58caee 100644 --- a/tests/suite/screens/views/waveform.py +++ b/tests/suite/screens/views/waveform.py @@ -14,6 +14,7 @@ from tests.suite.screens.dearpygui.geometry import Point, Rect from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.plots import settled_plot_pointer LINE_SERIES_TYPE: Final[str] = "mvAppItemType::mvLineSeries" FIRST_POINT: Final[int] = 0 @@ -60,13 +61,8 @@ def sample_under(self, fraction: float) -> float: ExpectationError: If the plot never reports the pointer resting on it. """ self._hand.move_to(self.point(fraction)) - self._bridge.expect( - lambda: bool(dpg.is_item_hovered(self.plot)), - bool, - description=f"the pointer resting on '{self.plot}'", - timeout=SETTLING_SECONDS, - ) - return float(self._bridge.ask(dpg.get_plot_mouse_pos)[FIRST_POINT]) + sample, _ = settled_plot_pointer(self._bridge, self.plot, timeout=SETTLING_SECONDS) + return sample def cursor(self) -> Optional[float]: """The sample the cursor marks, or ``None`` while no cursor stands on the plot.""" diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py index 40e83c6e6..6a5160263 100644 --- a/tests/suite/screens/world.py +++ b/tests/suite/screens/world.py @@ -12,6 +12,7 @@ from sampletones_shared.paths.user import CONFIG_PATH, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.screens.environment import SCREEN_SIZE from tests.suite.screens.seeds import ( + ArrangedProject, MiniLibrary, PlayableReconstruction, Recording, @@ -38,6 +39,12 @@ STEM_FRAMES: Final[int] = 60 PLAYABLE_FRAMES: Final[int] = 600 FRAMES_PER_SECOND: Final[int] = 60 +ARRANGED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Arranged.stp" +LINE: Final[str] = "Line" +BASS_VOICE: Final[str] = "Bass" +PAD: Final[str] = "Pad" +PAD_ROW: Final[int] = 4 +BASS_ROW: Final[int] = 8 TAKE_FREQUENCY: Final[float] = 220.0 @@ -183,3 +190,22 @@ def playing_world() -> World: StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), ), ) + + +def sequencer_world() -> World: + """A home holding :data:`ARRANGED_PROJECT`, its voices placed on its first pattern, and the playing world's files. + + The project's sample :data:`LINE` starts the pattern on Pulse 1, its hand-written :data:`PAD` + comes in on Pulse 2 at :data:`PAD_ROW`, and its sample :data:`BASS_VOICE` on the triangle at + :data:`BASS_ROW`. + """ + playing = playing_world() + return World( + state=playing.state, + application_config=None, + config=None, + files=( + *playing.files, + ArrangedProject(ARRANGED_PROJECT, line=LINE, bass=BASS_VOICE, pad=PAD, pad_row=PAD_ROW, bass_row=BASS_ROW), + ), + ) From 0ada66c37914dfca154575c7bbcbed800aefe589 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 18:58:48 +0200 Subject: [PATCH 12/19] Proved: exports, imports, instruments from a channel and the NSF window on a live screen --- docs/development/application/screens.md | 10 +- docs/development/bugs-and-todos.md | 4 + tests/screens/conftest.py | 2 + tests/screens/exports/__init__.py | 0 tests/screens/exports/conftest.py | 9 + tests/screens/exports/test_import.py | 315 ++++++++++ .../screens/exports/test_instrument_doors.py | 226 ++++++++ .../exports/test_instrument_from_channel.py | 141 +++++ tests/screens/exports/test_nsf_window.py | 225 ++++++++ tests/screens/exports/test_progress.py | 544 ++++++++++++++++++ tests/screens/sequencer/test_history.py | 18 +- tests/screens/sequencer/test_song.py | 12 +- tests/screens/sequencer/test_tracker.py | 11 +- tests/screens/sequencer/test_voices.py | 18 +- tests/suite/bitphase.py | 2 +- tests/suite/screens/boundaries/dialogs.py | 96 +++- tests/suite/screens/holds.py | 71 ++- tests/suite/screens/plugin.py | 21 +- tests/suite/screens/screen.py | 18 +- tests/suite/screens/seeds.py | 200 ++++++- tests/suite/screens/steps/sequencer.py | 29 +- tests/suite/screens/views/context_menu.py | 65 +++ tests/suite/screens/views/exports.py | 189 ++++++ tests/suite/screens/views/instruments.py | 16 +- tests/suite/screens/views/sequencer.py | 18 +- tests/suite/screens/world.py | 73 ++- 26 files changed, 2240 insertions(+), 93 deletions(-) create mode 100644 tests/screens/exports/__init__.py create mode 100644 tests/screens/exports/conftest.py create mode 100644 tests/screens/exports/test_import.py create mode 100644 tests/screens/exports/test_instrument_doors.py create mode 100644 tests/screens/exports/test_instrument_from_channel.py create mode 100644 tests/screens/exports/test_nsf_window.py create mode 100644 tests/screens/exports/test_progress.py create mode 100644 tests/suite/screens/views/exports.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 9fc3e5409..52d6310f2 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -153,7 +153,9 @@ and a scenario reads as the gestures a user makes. ## The boundaries - **File dialogs.** A native dialog stops the queue while it stands, so a scenario queues its answer before - the gesture that opens the dialog, and the stand-in answers at once. + the gesture that opens the dialog, and the stand-in answers at once. An answer names a folder that stands + on the disk, as a native dialog's does, and a save may pick one of the file types the dialog offers by + its name. - **Audio.** The default output device plays into silence in real time, so playback runs and nothing is heard. A scenario can start on a machine offering no device at all. - **Programs.** An audit hook refuses every program the application tries to start: a file manager, a @@ -174,10 +176,12 @@ folder scan meets the folder's entries and then one entry after another that is on reporting while it is held, so Stop and Cancel are heard where the real work hears them. A held rebuild of an edited channel waits on its worker before it computes, so the edit is drawn while it is on its way, and the real rebuild runs once it is released. A scenario standing for a rebuild that breaks lets the held -rebuilds go as that failure, which reaches the application the way a rebuild that raised does. A hold lasts +rebuilds go as that failure, which reaches the application the way a rebuild that raised does. A held +export stops at one report of its stages, after its window has heard the stage, and goes on answering a +cancel there; a scenario can move the report it stops at, to land a cancel between two files. A hold lasts until the scenario releases it, and every hold is released before the scenario leaves, because leaving waits for the work in flight. A scenario asks for a hold through its fixture: `conversion_hold`, -`scan_hold` or `regeneration_hold`. +`scan_hold`, `regeneration_hold` or `export_hold`. ## What survives a restart diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 9992773ea..cf411c3c1 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -218,3 +218,7 @@ currently out of line. An entry leaves when the code meets the contract again. callback queued for a row before the rebuild reads an item that no longer exists (`_on_row_hovered`, "Item not found"). The Sequencer's screen scenarios forgive it by name; no scenario reproduces it on demand, since it rests on the order the queued callbacks run in. +* Export instrument... in a project whose samples were converted at two tunings does nothing the user + can see: `voice_instrument` raises the tuning error inside the menu's callback, so no message, no save + dialog and no file follow, while a Bitphase project or an NSF program of the same project stops with a + message. `tests/screens/exports/test_progress.py` reproduces it as a known failure. diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py index cbee6a1cb..d0ca62e7e 100644 --- a/tests/screens/conftest.py +++ b/tests/screens/conftest.py @@ -1,5 +1,6 @@ from tests.suite.screens.plugin import ( conversion_hold, + export_hold, output_device, pytest_configure, pytest_pyfunc_call, @@ -19,6 +20,7 @@ __all__ = [ "conversion_hold", + "export_hold", "output_device", "pytest_configure", "pytest_pyfunc_call", diff --git a/tests/screens/exports/__init__.py b/tests/screens/exports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/conftest.py b/tests/screens/exports/conftest.py new file mode 100644 index 000000000..9e28b9b9c --- /dev/null +++ b/tests/screens/exports/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.world import World, exporting_world + + +@pytest.fixture +def world() -> World: + """The home every export scenario starts in: the arranged project, and projects meeting an export's limits.""" + return exporting_world() diff --git a/tests/screens/exports/test_import.py b/tests/screens/exports/test_import.py new file mode 100644 index 000000000..a0bf48a0e --- /dev/null +++ b/tests/screens/exports/test_import.py @@ -0,0 +1,315 @@ +import operator +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, open_voice, open_voice_menu +from tests.suite.screens.views.history import HistoryLine, HistorySegment +from tests.suite.screens.world import ( + ARRANGED_PROJECT, + BASS_VOICE, + LINE, + PAD, + RELEASING_INSTRUMENT, + releasing_instrument, +) + +IMPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.import_instrument" +IMPORT_TITLE: Final[str] = "sequencer.voices.title.import_instrument_dialog" +EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" +NO_PROJECT_TITLE: Final[str] = "global.dialog.title.no_project_open" +NO_PROJECT_MESSAGE: Final[str] = "global.dialog.message.no_project_open" +IMPORTED_TITLE: Final[str] = "sequencer.voices.title.instrument_imported" +IMPORTED_TEMPLATE: Final[str] = "sequencer.voices.template.instrument_omissions" +RELEASE_POINT: Final[str] = "sequencer.voices.label.omission_release_point" +NOT_FOUND: Final[str] = "sequencer.voices.message.instrument_not_found" +READ_FAILED: Final[str] = "Failed to read an instrument from" +NO_FILE: Final[str] = "No instrument file at" +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and hold no instrument.\n" +KEPT: Final[str] = "Kept" +FOLDER: Final[str] = "kept" +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SETTLING_FRAMES: Final[int] = 20 +HALF: Final[int] = 2 + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def kept_folder() -> Path: + path = Path.cwd() / FOLDER + path.mkdir(parents=True, exist_ok=True) + return path + + +def choose_import(screen: Screen) -> None: + """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.voices.right_click_below_the_rows() + screen.expect(screen.context_menu.is_shown, bool, description="the list's menu") + screen.context_menu.choose(screen.words(IMPORT_INSTRUMENT)) + + +def export_kept(screen: Screen) -> Path: + """Exports the hand-written voice from its menu to the kept folder under the name :data:`KEPT`.""" + path = kept_folder() / f"{KEPT}{EXT_FILE_INSTRUMENT}" + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, path) + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + screen.expect(notice.is_shown, bool, description="the notice of the export") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return path + + +def segments(lines: Tuple[HistoryLine, ...]) -> List[Tuple[HistorySegment, ...]]: + return [tuple(line.segments) for line in lines] + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project left as it was opened, which leaves at once.""" + forgive_the_hover_race(screen) + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestImportWithNoProjectOpen: + """Import instrument... with no project open says so and asks for no file; with a project open, it asks for one.""" + + def test_it_says_so_and_asks_for_no_file(self, screen: Screen) -> None: + voices = screen.sequencer.voices + notice = voices.no_project_notice + asked: List[int] = [] + + def close_the_project(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.project.close() + + screen.expect(voices.names, operator.not_, description="no voices listed") + asked.append(len(screen.dialog_requests())) + + def import_says_no_project_is_open(screen: Screen) -> None: + choose_import(screen) + + screen.expect(notice.is_shown, bool, description="the notice") + assert notice.prompt.title() == screen.words(NO_PROJECT_TITLE) + assert notice.words() == screen.words(NO_PROJECT_MESSAGE) + assert len(screen.dialog_requests()) == asked[0] + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + def with_a_new_project_it_asks_for_a_file(screen: Screen) -> None: + screen.project.create() + screen.answer_next_dialog(DialogKind.OPEN, None) + + choose_import(screen) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked[0] + 1).__eq__, + description="the file asked for", + ) + request = screen.dialog_requests()[-1] + assert request.kind == DialogKind.OPEN + assert request.title == screen.words(IMPORT_TITLE) + assert not notice.is_shown() + assert voices.names() == [] + + screen.scenario( + close_the_project, + import_says_no_project_is_open, + with_a_new_project_it_asks_for_a_file, + leaving_asks_nothing, + ).run() + + +class TestAnExportedVoiceComesBack: + """An instrument exported and imported again: the dialog opens where it was written, Cancel changes nothing, and + the import adds one voice playing the same envelopes, with no notice, which one undo takes away. + """ + + def test_the_round_trip(self, screen: Screen) -> None: + voices = screen.sequencer.voices + history = screen.sequencer.history + instruments = screen.reconstructions.instruments + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + envelopes: List[Tuple[str, str]] = [] + + def export_the_hand_written_voice(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the hand-written voice open") + envelopes.append( + ( + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME), + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO), + ) + ) + + exported.append(export_kept(screen)) + + before.append(segments(history.lines())) + + def cancel_changes_nothing(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, None) + + choose_import(screen) + + screen.expect(lambda: screen.dialog_requests()[-1].kind == DialogKind.OPEN, bool, description="asked") + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests()[-1].initial_directory == exported[0].parent + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def the_import_adds_the_voice_quietly(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, exported[0]) + + choose_import(screen) + + screen.expect(voices.names, (VOICES + [KEPT]).__eq__, description="the voice imported") + screen.frames(SETTLING_FRAMES) + assert not voices.imported_notice.is_shown() + assert len(history.lines()) == len(before[0]) + 1 + + def it_plays_the_same_envelopes(screen: Screen) -> None: + open_voice(screen, KEPT) + + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + KEPT.__eq__, + description="the imported voice open", + ) + assert ( + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME), + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO), + ) == envelopes[0] + + def one_undo_takes_it_away(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(voices.names, VOICES.__eq__, description="the import undone") + + screen.scenario( + export_the_hand_written_voice, + cancel_changes_nothing, + the_import_adds_the_voice_quietly, + it_plays_the_same_envelopes, + one_undo_takes_it_away, + leaving_asks_nothing, + ).run() + + +class TestAFileStatingMore: + """A FamiTracker instrument stating a release point imports, and a notice names the release point.""" + + def test_the_notice_names_what_it_left(self, screen: Screen) -> None: + voices = screen.sequencer.voices + notice = voices.imported_notice + + def import_it(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, releasing_instrument()) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description="the notice") + assert voices.names() == VOICES + [RELEASING_INSTRUMENT] + assert notice.prompt.title() == screen.words(IMPORTED_TITLE) + words = notice.words() + assert screen.words(IMPORTED_TEMPLATE).format(name=RELEASING_INSTRUMENT) in words + assert screen.words(RELEASE_POINT) in words + notice.dismiss() + + def undo_and_leave(screen: Screen) -> None: + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, VOICES.__eq__, description="the import undone") + + screen.scenario(import_it, undo_and_leave, leaving_asks_nothing).run() + + +@dataclass(frozen=True) +class BrokenFile: + """A file the import meets broken: how it is laid in the home, and what the application logs on meeting it.""" + + name: str + lay: Callable[[Path, Path], None] + logged: str + not_found: bool + + +def foreign(destination: Path, _exported: Path) -> None: + destination.write_bytes(FOREIGN_BYTES) + + +def truncated(destination: Path, exported: Path) -> None: + content = exported.read_bytes() + destination.write_bytes(content[: len(content) // HALF]) + + +def missing(_destination: Path, _exported: Path) -> None: + """Lays nothing: the file is gone by the time the import reads it.""" + + +BROKEN_FILES: Final[Tuple[BrokenFile, ...]] = ( + BrokenFile(name="Foreign", lay=foreign, logged=READ_FAILED, not_found=False), + BrokenFile(name="Truncated", lay=truncated, logged=READ_FAILED, not_found=False), + BrokenFile(name="Missing", lay=missing, logged=NO_FILE, not_found=True), +) + + +class TestABrokenInstrumentFile: + """A file that is no instrument, a truncated one and a missing one each give a message naming the trouble, and + leave the voices and the history as they stood. + """ + + def test_each_is_reported_and_changes_nothing(self, screen: Screen) -> None: + voices = screen.sequencer.voices + history = screen.sequencer.history + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + + def export_a_whole_file(screen: Screen) -> None: + exported.append(export_kept(screen)) + before.append(segments(history.lines())) + + def importing(broken: BrokenFile) -> None: + path = kept_folder() / f"{broken.name}{EXT_FILE_INSTRUMENT}" + broken.lay(path, exported[0]) + notice = screen.file_not_found_notice if broken.not_found else screen.error_notice + screen.answer_next_dialog(DialogKind.OPEN, path) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description=f"the message about {broken.name}") + screen.claim_error(broken.logged) + if broken.not_found: + assert screen.words(NOT_FOUND) in notice.words() + assert str(path) in notice.words() + + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the message gone") + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def import_each_broken_file(screen: Screen) -> None: + for broken in BROKEN_FILES: + importing(broken) + + screen.scenario(export_a_whole_file, import_each_broken_file, leaving_asks_nothing).run() diff --git a/tests/screens/exports/test_instrument_doors.py b/tests/screens/exports/test_instrument_doors.py new file mode 100644 index 000000000..94b1fa136 --- /dev/null +++ b/tests/screens/exports/test_instrument_doors.py @@ -0,0 +1,226 @@ +import operator +from pathlib import Path +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.instrument import read_fti +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_NSF +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items import EntryReading +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice, open_voice_menu +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" +EXPORT_TITLE: Final[str] = "reconstructions.instruments.title.export_instrument_dialog" +EXPORTED: Final[str] = "reconstructions.instruments.message.export_instrument_success" +FAMITRACKER_TYPE: Final[str] = "global.dialog.filter.famitracker_instrument" +BITPHASE_PRESET_TYPE: Final[str] = "global.dialog.filter.bitphase_preset" +NSF_TYPE: Final[str] = "global.dialog.filter.nsf" +FTI_MAGIC: Final[bytes] = b"FTI" +JSON_OPENING: Final[bytes] = b"{" +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +EMPTY: Final[str] = "" +RENAMED: Final[str] = "Renamed" +EXPORTS: Final[str] = "exports" + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def folder(*parts: str) -> Path: + """A folder of the home a save dialog can answer with, which stands on the disk as a native dialog's answer does.""" + path = Path.cwd().joinpath(EXPORTS, *parts) + path.mkdir(parents=True, exist_ok=True) + return path + + +def export_entry(screen: Screen) -> Optional[EntryReading]: + """The plain Export instrument... entry of the open menu, if it offers one.""" + words = screen.words(EXPORT_INSTRUMENT) + return next((entry for entry in screen.context_menu.entries() if entry.label == words), None) + + +def export_from_the_menu(screen: Screen, voice: str) -> None: + """Chooses Export instrument... on the menu of ``voice``, a voice holding one instrument.""" + open_voice_menu(screen, voice) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +def written(screen: Screen, path: Path) -> None: + """Waits for the notice naming the instrument file ``path`` written, and dismisses it.""" + notice = screen.exports.file_notice + screen.expect(notice.is_shown, bool, description=f"the notice of {path.name}") + words = notice.words() + assert screen.words(EXPORTED) in words + assert str(path) in words + assert path.is_file() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project only exported from, which leaves at once: an export changes nothing in the project.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestTheExportEntryFollowsWhatAVoiceHolds: + """Export instrument... is one entry on a voice holding one instrument and a submenu of channels on a sample holding + several; a voice holding none greys it, and greys the Reconstructions tab's button with it. + """ + + def test_one_entry_a_submenu_and_a_greyed_entry(self, screen: Screen) -> None: + menu = screen.context_menu + instruments = screen.reconstructions.instruments + + def a_hand_written_voice_offers_one_entry(screen: Screen) -> None: + open_voice_menu(screen, PAD) + + entry = export_entry(screen) + assert entry is not None + assert entry.enabled + assert screen.words(EXPORT_INSTRUMENT) not in menu.submenus() + menu.dismiss() + + def a_sample_of_three_channels_names_each(screen: Screen) -> None: + open_voice_menu(screen, LINE) + + assert export_entry(screen) is None + assert menu.submenu(screen.words(EXPORT_INSTRUMENT)) == [ + (screen.channel_words(channel), True) for channel in LINE_CHANNELS + ] + menu.dismiss() + + def a_sample_of_one_channel_offers_one_entry(screen: Screen) -> None: + open_voice_menu(screen, BASS_VOICE) + + entry = export_entry(screen) + assert entry is not None + assert entry.enabled + assert screen.words(EXPORT_INSTRUMENT) not in menu.submenus() + menu.dismiss() + + def a_voice_left_without_envelopes_greys_both(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the hand-written voice open") + assert instruments.can_export(INSTRUMENT_CHANNEL) + + for feature in (FeatureKey.VOLUME, FeatureKey.ARPEGGIO): + instruments.type_envelope(INSTRUMENT_CHANNEL, feature, EMPTY) + + screen.expect( + lambda: instruments.can_export(INSTRUMENT_CHANNEL), + operator.not_, + description="the button greyed", + ) + open_voice_menu(screen, PAD) + entry = export_entry(screen) + assert entry is not None + assert not entry.enabled + menu.dismiss() + + screen.scenario( + a_hand_written_voice_offers_one_entry, + a_sample_of_three_channels_names_each, + a_sample_of_one_channel_offers_one_entry, + a_voice_left_without_envelopes_greys_both, + leave_letting_the_project_go, + ).run() + + +class TestEveryDoorWritesTheSameBytes: + """The Reconstructions tab's button and the voice's menu write the same file for the same voice.""" + + def test_the_button_and_the_menu_agree(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + through_the_button = folder("button") / f"{PAD}{EXT_FILE_INSTRUMENT}" + through_the_menu = folder("menu") / f"{PAD}{EXT_FILE_INSTRUMENT}" + + def export_through_the_button(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the hand-written voice open") + screen.answer_next_dialog(DialogKind.SAVE, through_the_button) + + instruments.export(INSTRUMENT_CHANNEL) + + written(screen, through_the_button) + + def export_through_the_menu(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, through_the_menu) + + export_from_the_menu(screen, PAD) + + written(screen, through_the_menu) + assert through_the_menu.read_bytes() == through_the_button.read_bytes() + + screen.scenario(export_through_the_button, export_through_the_menu, leaving_asks_nothing).run() + + +class TestOneDialogOffersEveryType: + """One save dialog offers the three instrument types: the type picked decides the format, and the name the + instrument inside. + """ + + def test_the_picked_type_decides_the_format(self, screen: Screen) -> None: + types = (FAMITRACKER_TYPE, BITPHASE_PRESET_TYPE, NSF_TYPE) + offered: List[Tuple[str, ...]] = [] + + def export_picking(screen: Screen, type_key: str, extension: str) -> Path: + stem = folder() / PAD + screen.answer_next_save_as(stem, screen.words(type_key)) + + export_from_the_menu(screen, PAD) + + path = stem.with_suffix(extension) + written(screen, path) + request = screen.dialog_requests()[-1] + assert request.title == screen.words(EXPORT_TITLE) + assert request.suggested_name == PAD + offered.append(tuple(file_filter.name for file_filter in request.filters)) + return path + + def the_nsf_type_writes_a_program(screen: Screen) -> None: + path = export_picking(screen, NSF_TYPE, EXT_FILE_NSF) + + assert path.read_bytes().startswith(NSF_MAGIC) + assert offered[0] == tuple(screen.words(key) for key in types) + + def the_bitphase_type_writes_a_preset(screen: Screen) -> None: + path = export_picking(screen, BITPHASE_PRESET_TYPE, EXT_FILE_JSON) + + assert path.read_bytes().lstrip().startswith(JSON_OPENING) + + def the_famitracker_type_writes_an_instrument_named_after_the_file(screen: Screen) -> None: + path = export_picking(screen, FAMITRACKER_TYPE, EXT_FILE_INSTRUMENT) + + assert path.read_bytes().startswith(FTI_MAGIC) + assert read_fti(path).name == PAD + + def a_file_renamed_in_the_dialog_renames_the_instrument(screen: Screen) -> None: + path = folder() / f"{RENAMED}{EXT_FILE_INSTRUMENT}" + screen.answer_next_dialog(DialogKind.SAVE, path) + + export_from_the_menu(screen, PAD) + + written(screen, path) + assert read_fti(path).name == RENAMED + + screen.scenario( + the_nsf_type_writes_a_program, + the_bitphase_type_writes_a_preset, + the_famitracker_type_writes_an_instrument_named_after_the_file, + a_file_renamed_in_the_dialog_renames_the_instrument, + leaving_asks_nothing, + ).run() diff --git a/tests/screens/exports/test_instrument_from_channel.py b/tests/screens/exports/test_instrument_from_channel.py new file mode 100644 index 000000000..e38c97701 --- /dev/null +++ b/tests/screens/exports/test_instrument_from_channel.py @@ -0,0 +1,141 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.naming import instrument_slice_name +from tests.suite.screens.application import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import voice_title +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, open_voice, open_voice_menu +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +INSTRUMENT_FROM: Final[str] = "sequencer.voices.label.context_instrument_from" +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project every change of which was undone, which leaves at once.""" + forgive_the_hover_race(screen) + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestTheSubmenuNamesTheChannelsThatPlay: + """New instrument from names each channel a sample plays, one channel among them; a hand-written voice greys it.""" + + def test_each_channel_one_channel_and_none(self, screen: Screen) -> None: + menu = screen.context_menu + + def a_sample_of_three_channels_names_each(screen: Screen) -> None: + open_voice_menu(screen, LINE) + + assert menu.submenu(screen.words(INSTRUMENT_FROM)) == [ + (screen.channel_words(channel), True) for channel in LINE_CHANNELS + ] + menu.dismiss() + + def a_sample_of_one_channel_still_names_it(screen: Screen) -> None: + open_voice_menu(screen, BASS_VOICE) + + assert menu.submenu(screen.words(INSTRUMENT_FROM)) == [(screen.channel_words(ChannelName.TRIANGLE), True)] + assert screen.words(INSTRUMENT_FROM) not in menu.labels() + menu.dismiss() + + def a_hand_written_voice_greys_it(screen: Screen) -> None: + open_voice_menu(screen, PAD) + + assert screen.words(INSTRUMENT_FROM) not in menu.submenus() + entry = next(entry for entry in menu.entries() if entry.label == screen.words(INSTRUMENT_FROM)) + assert not entry.enabled + menu.dismiss() + + screen.scenario( + a_sample_of_three_channels_names_each, + a_sample_of_one_channel_still_names_it, + a_hand_written_voice_greys_it, + leaving_asks_nothing, + ).run() + + +class TestTakingAChannel: + """Taking a channel adds ` ()` carrying that channel's envelopes and opens it; one undo takes it + away and leaves the sample as it was. + + The undo is the History card's, since the note keys of the instrument left open take Ctrl+Z (bugs-and-todos ยง + Bugs). + """ + + def test_each_channel_of_the_line(self, screen: Screen) -> None: + voices = screen.sequencer.voices + instruments = screen.reconstructions.instruments + played: Dict[ChannelName, str] = {} + + def read_what_each_channel_plays(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + lambda: instruments.tab_shown(ChannelName.NOISE), + bool, + description="the line's channels", + ) + + for channel in LINE_CHANNELS: + played[channel] = instruments.envelope(channel, FeatureKey.VOLUME) + + def taking(channel: ChannelName) -> None: + name = instrument_slice_name(LINE, channel) + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in(screen.words(INSTRUMENT_FROM), screen.channel_words(channel)) + + screen.expect(voices.names, (VOICES + [name]).__eq__, description=f"{name} listed") + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + name.__eq__, + description=f"{name} open", + ) + assert screen.tabs.front() == Tab.RECONSTRUCTIONS + assert instruments.offers_audition() + assert instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == played[channel] + + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.history.undo() + + screen.expect(voices.names, VOICES.__eq__, description=f"{name} taken away") + + def take_each_channel_and_undo_it(screen: Screen) -> None: + for channel in LINE_CHANNELS: + taking(channel) + + def the_line_plays_as_it_did(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + lambda: instruments.tab_shown(ChannelName.NOISE), + bool, + description="the line's channels", + ) + + assert {channel: instruments.envelope(channel, FeatureKey.VOLUME) for channel in LINE_CHANNELS} == played + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, 0, LINE, unsaved=False).__eq__, + description="the project as it was", + ) + + screen.scenario( + read_what_each_channel_plays, + take_each_channel_and_undo_it, + the_line_plays_as_it_did, + leaving_asks_nothing, + ).run() diff --git a/tests/screens/exports/test_nsf_window.py b/tests/screens/exports/test_nsf_window.py new file mode 100644 index 000000000..987c6fbc5 --- /dev/null +++ b/tests/screens/exports/test_nsf_window.py @@ -0,0 +1,225 @@ +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import ProjectExport +from sampletones_core.project import ProjectContainer +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.application import SAMPLETONES_COPYRIGHT +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.world import LOOPING_PROJECT, PLAYABLE_RECONSTRUCTION + +NSF_TITLE: Final[str] = "global.dialog.title.export_nsf_project" +NSF_TYPE: Final[str] = "global.dialog.filter.nsf" +NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" +INSTRUMENTS_EXPORTED: Final[str] = "reconstructions.instruments.message.export_instruments_success" +NO_CHANNEL: Final[str] = "settings.nsf.message.no_channel" +FROM_THE_START: Final[str] = "settings.nsf.label.repeat_from_start" +FROM_A_FRAME: Final[str] = "settings.nsf.label.repeat_from_frame" +HELD_SOUNDS: Final[str] = "settings.nsf.label.scheme_runs" +FULL_SEARCH: Final[str] = "settings.nsf.label.scheme_search" +TYPED_TITLE: Final[str] = "Rainy Day" +SECOND_FRAME: Final[str] = "01" +LOOP_FRAME: Final[int] = 1 +LEFT_OUT: Final[ChannelName] = ChannelName.NOISE +SILENT_IN_THE_RECONSTRUCTION: Final[ChannelName] = ChannelName.PULSE2 +SETTLING_FRAMES: Final[int] = 20 + + +def folder(name: str) -> Path: + """A folder of the home for the program, standing on the disk as a native dialog's answer does.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def write_chosen_program(destination: Path) -> None: + """Writes the program the window is set to in the scenario: noise left out, a repeat from the second frame, held sounds + stored once, and the typed title, the rest as the project states it. + """ + project = ProjectContainer.load(LOOPING_PROJECT) + program = NSFProgram( + information=NSFInformation( + title=TYPED_TITLE, + artist=project.info.author, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS - {LEFT_OUT}, + loop_tick=SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(LOOP_FRAME), + scheme=CompressionScheme.RUNS, + ) + NSFBackend.stated().choosing(program).write_project(destination, ProjectExport(project=project)) + + +def write_stated_program(destination: Path) -> None: + """Writes the program the project states for itself, the one the window opens on.""" + project = ProjectContainer.load(LOOPING_PROJECT) + NSFBackend.stated().choosing(NSFProgram.for_project(project)).write_project( + destination, + ProjectExport(project=project), + ) + + +class TestTheWindowReachesTheFile: + """Each choice made in the NSF window reaches the program it writes; with no channel ticked, Export waits.""" + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LOOPING_PROJECT) + + def test_the_program_written_is_the_one_set_up(self, screen: Screen) -> None: + window = screen.exports.nsf + into = folder("program") + destination = into / f"{LOOPING_PROJECT.stem}{EXT_FILE_NSF}" + asked: List[int] = [] + + def open_the_window(screen: Screen) -> None: + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert all(window.offers(channel) and window.is_ticked(channel) for channel in ChannelName.items()) + assert window.repeat() == screen.words(FROM_THE_START) + assert window.level() == screen.words(FULL_SEARCH) + assert not window.says_a_channel_is_needed() + asked.append(len(screen.dialog_requests())) + + def with_no_channel_export_waits(screen: Screen) -> None: + for channel in ChannelName.items(): + window.tick(channel) + + screen.expect(window.says_a_channel_is_needed, bool, description="the window asking for a channel") + assert not window.can_export() + window.press_export() + screen.frames(SETTLING_FRAMES) + assert window.is_shown() + assert len(screen.dialog_requests()) == asked[0] + assert list(into.iterdir()) == [] + + def a_channel_ticked_lets_it_go(screen: Screen) -> None: + for channel in ChannelName.items(): + if channel != LEFT_OUT: + window.tick(channel) + + screen.expect(window.can_export, bool, description="Export answering") + assert not window.says_a_channel_is_needed() + assert not window.is_ticked(LEFT_OUT) + + def set_the_repeat_the_level_and_the_title(screen: Screen) -> None: + window.choose_repeat(screen.words(FROM_A_FRAME)) + window.type_loop_frame(SECOND_FRAME) + window.choose_level(screen.words(HELD_SOUNDS)) + window.type_title(TYPED_TITLE) + + screen.expect(window.title, TYPED_TITLE.__eq__, description="the title typed") + assert window.repeat() == screen.words(FROM_A_FRAME) + assert window.loop_frame() == SECOND_FRAME + assert window.level() == screen.words(HELD_SOUNDS) + + def choose_the_file(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + window.browse() + + screen.expect(lambda: len(screen.dialog_requests()), (asked[0] + 1).__eq__, description="the file asked") + request = screen.dialog_requests()[-1] + assert request.title == screen.words(NSF_TITLE) + assert request.suggested_name == destination.name + assert [file_filter.name for file_filter in request.filters] == [screen.words(NSF_TYPE)] + + def the_program_written_is_the_one_set_up(screen: Screen) -> None: + notice = screen.exports.project_notice + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(NSF_EXPORTED) in notice.words() + assert not window.is_shown() + notice.dismiss() + chosen = folder("chosen") / destination.name + stated = folder("stated") / destination.name + write_chosen_program(chosen) + write_stated_program(stated) + assert destination.read_bytes() == chosen.read_bytes() + assert destination.read_bytes() != stated.read_bytes() + + screen.scenario( + open_the_window, + with_no_channel_export_waits, + a_channel_ticked_lets_it_go, + set_the_repeat_the_level_and_the_title, + choose_the_file, + the_program_written_is_the_one_set_up, + ).run() + + +class TestAReconstructionsSilentChannels: + """A reconstruction's program offers the channels it sounds alone: a silent channel's box is greyed and a press on + it ticks nothing. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_silent_channel_stays_out(self, screen: Screen) -> None: + window = screen.exports.nsf + destination = folder("reconstruction") / f"{PLAYABLE_RECONSTRUCTION.stem}{EXT_FILE_NSF}" + sounding = [channel for channel in ChannelName.items() if channel != SILENT_IN_THE_RECONSTRUCTION] + + def open_the_window(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.exports.export_reconstruction(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert not window.offers(SILENT_IN_THE_RECONSTRUCTION) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + assert all(window.offers(channel) and window.is_ticked(channel) for channel in sounding) + + def a_press_on_the_silent_channel_ticks_nothing(screen: Screen) -> None: + window.press_on(SILENT_IN_THE_RECONSTRUCTION) + + screen.frames(SETTLING_FRAMES) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def a_press_on_a_sounding_channel_lets_it_go_and_back(screen: Screen) -> None: + window.tick(LEFT_OUT) + screen.expect(lambda: window.is_ticked(LEFT_OUT), operator.not_, description="the noise let go") + + window.tick(LEFT_OUT) + + screen.expect(lambda: window.is_ticked(LEFT_OUT), bool, description="the noise ticked again") + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def the_program_is_written(screen: Screen) -> None: + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file") + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(INSTRUMENTS_EXPORTED) in notice.words() + assert destination.read_bytes().startswith(NSF_MAGIC) + notice.dismiss() + + screen.scenario( + open_the_window, + a_press_on_the_silent_channel_ticks_nothing, + a_press_on_a_sounding_channel_lets_it_go_and_back, + the_program_is_written, + ).run() diff --git a/tests/screens/exports/test_progress.py b/tests/screens/exports/test_progress.py new file mode 100644 index 000000000..cce6679ea --- /dev/null +++ b/tests/screens/exports/test_progress.py @@ -0,0 +1,544 @@ +import operator +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exports.stage import ExportStage +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, + EXT_FILE_NSF, +) +from tests.suite.bitphase import parse_btp +from tests.suite.famitracker import parse_ftm +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.holds import FIRST_REPORT, ExportHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.steps.sequencer import open_voice, open_voice_menu +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.world import ( + ARRANGED_PROJECT, + LINE, + LONG_ENVELOPES_PROJECT, + LONG_ITEMS, + LONG_VOICE, + MIDDLING_ITEMS, + MIDDLING_VOICE, + OVERLONG_PROJECT, + PAD, + PLAYABLE_RECONSTRUCTION, + TWO_TUNINGS_PROJECT, +) + +EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" +STAGE_WORDS: Final[Dict[ExportStage, str]] = { + ExportStage.WALKING: "settings.export.label.stage_walking", + ExportStage.COMPRESSING: "settings.export.label.stage_compressing", + ExportStage.WRITING: "settings.export.label.stage_writing", +} +SETTLING_FRAMES: Final[int] = 20 +FAILED_EXPORT: Final[str] = "Failed to export to" +NSF_FAILED: Final[str] = "global.dialog.message.nsf_project_export_failed" +NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" +BITPHASE_FAILED: Final[str] = "global.dialog.message.bitphase_project_export_failed" +BITPHASE_EXPORTED: Final[str] = "global.dialog.message.bitphase_project_exported_successfully" +MODULE_EXPORTED: Final[str] = "global.dialog.message.project_exported_successfully" +MODULE_FAILED: Final[str] = "global.dialog.message.project_export_failed" +READ_ONLY: Final[int] = 0o555 +WRITABLE: Final[int] = 0o755 +SHORTENED_PROJECT: Final[str] = "global.dialog.template.export_truncated" +STORED_TICK_BY_TICK: Final[str] = "settings.nsf.label.scheme_none" +EVERY_REPEAT_ONCE: Final[str] = "settings.nsf.label.scheme_search" +TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" +KEPT_BY_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +KEPT_BY_BITPHASE: Final[str] = "reconstructions.instruments.template.kept_bitphase" +KEPT_BY_PRESET: Final[str] = "reconstructions.instruments.template.kept_bitphase_preset" +KEPT_SEPARATOR: Final[str] = "reconstructions.instruments.template.kept_separator" +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +ASIDE: Final[Point] = Point(x=0, y=0) + + +def folder(name: str) -> Path: + """A folder of the home for one door's files, standing on the disk as a native dialog's answer does.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def click_cancel(screen: Screen) -> None: + screen.exports.progress.cancel() + + +def press_escape(screen: Screen) -> None: + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + +@dataclass(frozen=True) +class ExportDoor: + """One way an export is started, what its window shows first, and how the reader cancels it. + + Attributes: + name: The folder the door writes into, named after the door. + start: Starts the export into the folder it is given, answering the dialogs it opens. + first_stage: The stage the run reports first, which the window lists. + cancel: The reader's way of canceling the run. + project: Whether the export writes the project, whose outcome stands in the project's notice. + """ + + name: str + start: Callable[[Screen, Path], None] + first_stage: ExportStage + cancel: Callable[[Screen], None] + project: bool + + +def from_the_file_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{ARRANGED_PROJECT.stem}{extension}") + screen.exports.export_project(item) + + return start + + +def from_the_reconstruction_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PLAYABLE_RECONSTRUCTION.stem}{extension}") + screen.exports.export_reconstruction(item) + + return start + + +def through_the_nsf_window(menu_start: Callable[[Screen], None], stem: str) -> Callable[[Screen, Path], None]: + def start(screen: Screen, into: Path) -> None: + window = screen.exports.nsf + menu_start(screen) + screen.expect(window.is_shown, bool, description="the NSF window") + screen.answer_next_dialog(DialogKind.SAVE, into / f"{stem}{EXT_FILE_NSF}") + window.browse() + screen.expect( + lambda: screen.dialog_requests()[-1].answer == into / f"{stem}{EXT_FILE_NSF}", + bool, + description="the file chosen", + ) + window.export() + + return start + + +def from_the_voice_menu(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PAD}{EXT_FILE_INSTRUMENT}") + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +PROJECT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="module", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, EXT_FILE_MODULE), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=True, + ), + ExportDoor( + name="bitphase", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_BITPHASE, EXT_FILE_BITPHASE), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=True, + ), + ExportDoor( + name="program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF), + ARRANGED_PROJECT.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=True, + ), +) + +INSTRUMENT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="instruments", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + EXT_FILE_INSTRUMENT, + ), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="presets", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, + EXT_FILE_JSON, + ), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=False, + ), + ExportDoor( + name="sample program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_reconstruction( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF + ), + PLAYABLE_RECONSTRUCTION.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="instrument", + start=from_the_voice_menu, + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), +) + + +def outcome_notice(screen: Screen, door: ExportDoor) -> Notice: + return screen.exports.project_notice if door.project else screen.exports.file_notice + + +def canceled_and_then_written(screen: Screen, hold: ExportHold, door: ExportDoor) -> None: + """Starts ``door``'s export under the hold, cancels it, and starts it again unheld, which writes.""" + progress = screen.exports.progress + notice = outcome_notice(screen, door) + into = folder(door.name) + hold.hold_at(FIRST_REPORT) + + door.start(screen, into) + + screen.expect(progress.is_shown, bool, description=f"the {door.name} export under way") + screen.expect(hold.waiting, (1).__eq__, description=f"the {door.name} export held") + assert progress.stages() == (screen.words(STAGE_WORDS[door.first_stage]),) + + door.cancel(screen) + + screen.expect(progress.is_shown, operator.not_, description=f"the {door.name} export canceled") + screen.frames(SETTLING_FRAMES) + assert hold.waiting() == 0 + assert not notice.is_shown() + assert list(into.iterdir()) == [] + + hold.release() + door.start(screen, into) + + screen.expect(notice.is_shown, bool, description=f"the {door.name} export written") + assert not progress.is_shown() + assert list(into.iterdir()) != [] + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +class TestEveryExportAnswersCancel: + """Every export shows its window while it runs and stops at a cancel with no file written; let run, it writes. + + Cancel is clicked on some doors and Escape pressed on others. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + def test_the_project_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_project_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in PROJECT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_project_export).run() + + def test_the_instrument_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_instrument_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in INSTRUMENT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_instrument_export).run() + + +def refused(screen: Screen, failed_key: str, destination: Path) -> None: + """Waits for the error naming the export ``failed_key`` words, and checks nothing was written to ``destination``.""" + notice = screen.error_notice + screen.expect(notice.is_shown, bool, description="the export refused") + screen.claim_error(FAILED_EXPORT) + assert screen.words(failed_key) in notice.words() + assert not destination.exists() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the error gone") + + +def written_project(screen: Screen, exported_key: str, destination: Path) -> str: + """Waits for the notice of the project export ``exported_key`` words, dismisses it, and answers what it said.""" + notice = screen.exports.project_notice + screen.expect(notice.is_shown, bool, description=f"{destination.name} written") + words = notice.words() + assert screen.words(exported_key) in words + assert destination.is_file() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return words + + +def export_the_program(screen: Screen, level_key: str, destination: Path) -> None: + """Opens File โ–ธ Export โ–ธ NSF program..., picks the level ``level_key`` words and the file, and presses Export.""" + window = screen.exports.nsf + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + screen.expect(window.is_shown, bool, description="the NSF window") + window.choose_level(screen.words(level_key)) + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file chosen") + assert window.level() == screen.words(level_key) + + window.export() + + +class TestASongTooLargeForTheProgram: + """A song stored tick by tick past the room an NSF program has is refused with a message, and nothing is written; + stored with each repeat saved once, it is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=OVERLONG_PROJECT) + + def test_it_is_refused_and_then_fits(self, screen: Screen) -> None: + destination = folder("overlong") / f"{OVERLONG_PROJECT.stem}{EXT_FILE_NSF}" + + def stored_tick_by_tick_it_is_refused(screen: Screen) -> None: + export_the_program(screen, STORED_TICK_BY_TICK, destination) + + refused(screen, NSF_FAILED, destination) + + def saving_each_repeat_once_it_is_written(screen: Screen) -> None: + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + written_project(screen, NSF_EXPORTED, destination) + assert destination.read_bytes().startswith(NSF_MAGIC) + + screen.scenario(stored_tick_by_tick_it_is_refused, saving_each_repeat_once_it_is_written).run() + + +class TestTwoTunings: + """A project whose samples were converted at two tunings: a song sounding one tuning stops with a message and + writes nothing, and a module, which keeps each sample's own pitches, is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=TWO_TUNINGS_PROJECT) + + def test_a_song_sounding_one_tuning_stops(self, screen: Screen) -> None: + into = folder("tunings") + + def a_bitphase_project_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + refused(screen, BITPHASE_FAILED, destination) + + def a_program_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_NSF}" + + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + refused(screen, NSF_FAILED, destination) + + def a_module_is_written(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(a_bitphase_project_stops, a_program_stops, a_module_is_written).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: Export instrument... in a project of two tunings fails with no message", + ) + def test_an_instrument_export_stops_with_a_message(self, screen: Screen) -> None: + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in( + screen.words(EXPORT_INSTRUMENT), + screen.channel_words(INSTRUMENT_CHANNEL), + ) + + screen.expect(screen.error_notice.is_shown, bool, description="the export refused with a message") + + +def written_for(names: List[str], voices: Tuple[str, ...]) -> int: + """How many of the instruments a file lists under ``names`` were written for one of ``voices``.""" + return sum(1 for name in names if any(name == voice or name.startswith(f"{voice} (") for voice in voices)) + + +class TestShortenedEnvelopes: + """An envelope longer than a format stores: its field's status names each export that cuts it and what each keeps, + and a project export says how many of the instruments it wrote it shortened. + + The long voice runs past every format's limit, and the middling one past FamiTracker's alone. A format writes a + voice as one instrument or several, so the count the notice gives is read against the instruments the file lists. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LONG_ENVELOPES_PROJECT) + + def test_the_status_and_the_notices_name_the_cuts(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + into = folder("shortened") + + def status_of(voice: str) -> str: + open_voice(screen, voice) + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + voice.__eq__, + description=f"{voice} open", + ) + screen.hand.move_to(ASIDE) + instruments.hover_field(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) + return screen.expect(screen.status, bool, description=f"the status of {voice}'s volume") + + def too_long(items: int, *kept: str) -> str: + return screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=items, + kept=screen.words(KEPT_SEPARATOR).join(kept), + ) + + def the_long_voice_names_every_format(screen: Screen) -> None: + status = status_of(LONG_VOICE) + + assert status == too_long( + LONG_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + screen.words(KEPT_BY_BITPHASE).format(limit=MAX_MACRO_LENGTH), + screen.words(KEPT_BY_PRESET).format(limit=MAX_MACRO_LENGTH), + ) + assert instruments.field_theme(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == TAG_GLOBAL_THEME_INPUT_WARNING + + def the_middling_voice_names_famitracker_alone(screen: Screen) -> None: + status = status_of(MIDDLING_VOICE) + + assert status == too_long( + MIDDLING_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + + def a_bitphase_project_shortens_one(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + words = written_project(screen, BITPHASE_EXPORTED, destination) + names = [instrument.name for instrument in parse_btp(destination.read_bytes(), []).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE,)), + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ITEMS, + ) + in words + ) + + def a_module_shortens_both(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + words = written_project(screen, MODULE_EXPORTED, destination) + names = [instrument.name for instrument in parse_ftm(destination.read_bytes()).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE, MIDDLING_VOICE)), + frames=MAX_SEQUENCE_ITEMS, + source_frames=LONG_ITEMS, + ) + in words + ) + + screen.scenario( + the_long_voice_names_every_format, + the_middling_voice_names_famitracker_alone, + a_bitphase_project_shortens_one, + a_module_shortens_both, + ).run() + + +class TestAnExportFailingUnderItsWindow: + """An export failing while its window stands: the window goes, one error says what failed, and nothing is written; + once the folder takes files again, the same export writes. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_the_error_follows_the_window(self, screen: Screen, export_hold: ExportHold) -> None: + progress = screen.exports.progress + into = folder("locked") + destination = into / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_the_module_export(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + screen.expect(progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def the_folder_refuses_the_file(screen: Screen) -> None: + into.chmod(READ_ONLY) + try: + export_hold.release() + + screen.expect(screen.error_notice.is_shown, bool, description="the export failed") + finally: + into.chmod(WRITABLE) + + screen.claim_error(FAILED_EXPORT) + assert not progress.is_shown() + standing = screen.error_notice.prompt.window() + assert standing is not None + assert [window.alias for window in screen.shown_windows()] == [standing.alias] + assert screen.words(MODULE_FAILED) in screen.error_notice.words() + assert list(into.iterdir()) == [] + screen.error_notice.dismiss() + screen.expect(screen.error_notice.is_shown, operator.not_, description="the error gone") + + def the_same_export_then_writes(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(start_the_module_export, the_folder_refuses_the_file, the_same_export_then_writes).run() diff --git a/tests/screens/sequencer/test_history.py b/tests/screens/sequencer/test_history.py index 2d354696a..634242d57 100644 --- a/tests/screens/sequencer/test_history.py +++ b/tests/screens/sequencer/test_history.py @@ -13,14 +13,13 @@ from tests.suite.screens.dearpygui.keys import IMGUI_ENTER from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import titled -from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go, open_voice from tests.suite.screens.views.history import HistoryLine from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD SETTLING_FRAMES: Final[int] = 10 RENAMED: Final[str] = "Renamed" DUPLICATE: Final[str] = "sequencer.voices.label.context_duplicate" -HOVER_RACE: Final[str] = "Error executing callback _on_row_hovered" VOLUME_LETTER: Final[str] = "v" ARPEGGIO_LETTER: Final[str] = "a" QUIET: Final[float] = 4.0 @@ -165,7 +164,7 @@ def step(screen: Screen) -> None: def leave_as_opened(screen: Screen) -> None: """Exits a project every gesture of which was undone, which leaves at once.""" - screen.forgive_known_error(HOVER_RACE) + forgive_the_hover_race(screen) assert screen.title() == titled(screen, ARRANGED_PROJECT.stem) screen.press_shortcut(ShortcutId.EXIT) @@ -173,17 +172,6 @@ def leave_as_opened(screen: Screen) -> None: assert screen.wait_for_exit() -def leave_letting_the_project_go(screen: Screen) -> None: - screen.forgive_known_error(HOVER_RACE) - prompt = screen.project.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the project") - - prompt.confirm() - - assert screen.wait_for_exit() - - class TestEachVoiceGestureIsOneEntry: """Renaming, duplicating, moving and removing a sample or an instrument each make one entry. @@ -217,7 +205,7 @@ def test_every_line_names_the_voice_by_position_and_name(self, screen: Screen) - screen.press_shortcut(ShortcutId.UNDO) screen.expect(screen.sequencer.voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="undone") - screen.forgive_known_error(HOVER_RACE) + forgive_the_hover_race(screen) assert all(names_by_position_and_name(line, LINE, LINE_POSITION) for line in lines) def test_a_new_instrument_is_one_entry(self, screen: Screen) -> None: diff --git a/tests/screens/sequencer/test_song.py b/tests/screens/sequencer/test_song.py index 0c484c230..475c6e145 100644 --- a/tests/screens/sequencer/test_song.py +++ b/tests/screens/sequencer/test_song.py @@ -13,7 +13,7 @@ from tests.suite.screens.application import Startup from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import marked, raise_the_first_level, titled, voice_title -from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice from tests.suite.screens.world import ARRANGED_PROJECT, LINE SETTLING_FRAMES: Final[int] = 20 @@ -51,16 +51,6 @@ def on_the_sequencer(screen: Screen) -> None: screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") -def leave_letting_the_project_go(screen: Screen) -> None: - prompt = screen.project.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the project") - - prompt.confirm() - - assert screen.wait_for_exit() - - class TestTheOrderTable: """An order entry typed names the pattern a channel plays at that frame, and the tracker follows it.""" diff --git a/tests/screens/sequencer/test_tracker.py b/tests/screens/sequencer/test_tracker.py index 93a1a2bd5..a3487166c 100644 --- a/tests/screens/sequencer/test_tracker.py +++ b/tests/screens/sequencer/test_tracker.py @@ -13,6 +13,7 @@ from tests.suite.screens.dearpygui.items import read_label from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go from tests.suite.screens.views.tracker import tracker_cell, tracker_cell_theme from tests.suite.screens.world import ARRANGED_PROJECT, BASS_ROW, PAD_ROW @@ -63,16 +64,6 @@ def type_into(screen: Screen, row: int, channel: Optional[ChannelName], subcolum screen.frames(TYPING_FRAMES) -def leave_letting_the_project_go(screen: Screen) -> None: - prompt = screen.project.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the project") - - prompt.confirm() - - assert screen.wait_for_exit() - - @pytest.fixture def startup() -> Startup: return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/sequencer/test_voices.py b/tests/screens/sequencer/test_voices.py index 276868030..9a62842f2 100644 --- a/tests/screens/sequencer/test_voices.py +++ b/tests/screens/sequencer/test_voices.py @@ -18,6 +18,7 @@ from tests.suite.screens.keyboard import press_combination from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import BY_CONFIGURATION +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go from tests.suite.screens.views.menus import MenuEntry from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD, SHORT_RECONSTRUCTION @@ -40,7 +41,6 @@ MOVE_DOWN_KEYS: Final[str] = "Alt+Down" PLACED_ROW: Final[int] = 1 ADDED_NUMBER: Final[str] = "03" -HOVER_RACE: Final[str] = "Error executing callback _on_row_hovered" @pytest.fixture @@ -77,22 +77,6 @@ def menu_on_the_bar(screen: Screen, group: MenuElements) -> List[MenuEntry]: return entries -def forgive_the_hover_race(screen: Screen) -> None: - """Forgives the error a voice row's hover meets once the list has rebuilt the row, which the ledger records.""" - screen.forgive_known_error(HOVER_RACE) - - -def leave_letting_the_project_go(screen: Screen) -> None: - forgive_the_hover_race(screen) - prompt = screen.project.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the project") - - prompt.confirm() - - assert screen.wait_for_exit() - - class TestTheVoicesMenus: """A voice row's menu offers the voice's actions and the pool's; the empty list offers the pool's alone. diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index 2312ded3b..d3e191231 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -253,7 +253,7 @@ def sounded_period( int: The period the channel holds, within the timer's range. """ moved = reached_note(tuning_table, note_index, table, tick) - period = tuning_table[moved] + instrument.value("toneAdd", tick) + period = tuning_table[moved] + int(instrument.value("toneAdd", tick)) return min(max(period, BITPHASE_SILENT_PERIOD), BITPHASE_MAX_PERIOD) diff --git a/tests/suite/screens/boundaries/dialogs.py b/tests/suite/screens/boundaries/dialogs.py index 31a50b0ca..051861ce2 100644 --- a/tests/suite/screens/boundaries/dialogs.py +++ b/tests/suite/screens/boundaries/dialogs.py @@ -3,12 +3,12 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from typing import Deque, Dict, List, Optional, Tuple +from typing import Deque, Dict, Final, List, Optional, Tuple import pytest import sampletones_application.utils.file_dialogs.api as file_dialogs_api -from sampletones_application.utils.file_dialogs.destination import SaveDestination, untyped_destination +from sampletones_application.utils.file_dialogs.destination import SaveDestination from sampletones_application.utils.file_dialogs.filter import FileFilter @@ -18,6 +18,22 @@ class DialogKind(StrEnum): DIRECTORY = "directory" +UNTYPED: Final[None] = None + + +@dataclass(frozen=True) +class DialogAnswer: + """What a scenario has a dialog answer: a path, and for a save dialog the file type picked in its selector. + + Attributes: + path: The path the dialog answers with. + type_name: The name of the offered file type the reader picks, or ``None`` to leave the selector alone. + """ + + path: Path + type_name: Optional[str] + + @dataclass(frozen=True) class DialogRequest: """One file dialog the application opened during a scenario. @@ -50,7 +66,7 @@ class ScriptedFileDialogs: """ def __init__(self) -> None: - self._answers: Dict[DialogKind, Deque[Optional[Path]]] = {kind: deque() for kind in DialogKind} + self._answers: Dict[DialogKind, Deque[Optional[DialogAnswer]]] = {kind: deque() for kind in DialogKind} self._requests: List[DialogRequest] = [] self._lock = threading.Lock() @@ -65,7 +81,19 @@ def answer( ) -> None: """Queues the answer the next dialog of ``kind`` gives: ``path``, or ``None`` to dismiss it.""" with self._lock: - self._answers[kind].append(path) + self._answers[kind].append(None if path is None else DialogAnswer(path=path, type_name=UNTYPED)) + + def answer_save_as( + self, + path: Path, + type_name: str, + ) -> None: + """Queues the answer the next save dialog gives: ``path``, with the offered type named ``type_name`` picked. + + A dialog offering no type of that name is left unanswered, which the after-checks report. + """ + with self._lock: + self._answers[DialogKind.SAVE].append(DialogAnswer(path=path, type_name=type_name)) @property def requests(self) -> Tuple[DialogRequest, ...]: @@ -83,12 +111,14 @@ def open_file( initial_directory: Optional[Path], filters: Tuple[FileFilter, ...], ) -> Optional[Path]: - return self._take( - DialogKind.OPEN, - title=title, - initial_directory=initial_directory, - suggested_name=None, - filters=filters, + return _path( + self._take( + DialogKind.OPEN, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=filters, + ) ) def save_file( @@ -106,7 +136,10 @@ def save_file( suggested_name=suggested_name, filters=filters, ) - return untyped_destination(answer) + if answer is None: + return None + + return SaveDestination(path=answer.path, file_type=_picked(answer, filters)) def select_directory( self, @@ -114,12 +147,14 @@ def select_directory( title: str, initial_directory: Optional[Path], ) -> Optional[Path]: - return self._take( - DialogKind.DIRECTORY, - title=title, - initial_directory=initial_directory, - suggested_name=None, - filters=(), + return _path( + self._take( + DialogKind.DIRECTORY, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=(), + ) ) def _take( @@ -130,11 +165,15 @@ def _take( initial_directory: Optional[Path], suggested_name: Optional[str], filters: Tuple[FileFilter, ...], - ) -> Optional[Path]: + ) -> Optional[DialogAnswer]: with self._lock: queued = self._answers[kind] answered = bool(queued) answer = queued.popleft() if answered else None + if answer is not None and answer.type_name is not None and not _offers(filters, answer.type_name): + answered = False + answer = None + self._requests.append( DialogRequest( kind=kind, @@ -143,8 +182,27 @@ def _take( suggested_name=suggested_name, filters=filters, answered=answered, - answer=answer, + answer=_path(answer), ) ) return answer + + +def _path(answer: Optional[DialogAnswer]) -> Optional[Path]: + return None if answer is None else answer.path + + +def _offers( + filters: Tuple[FileFilter, ...], + type_name: str, +) -> bool: + return any(file_filter.name == type_name for file_filter in filters) + + +def _picked( + answer: DialogAnswer, + filters: Tuple[FileFilter, ...], +) -> Optional[FileFilter]: + """The offered type the answer picks, or ``None`` for an answer leaving the selector alone.""" + return next((file_filter for file_filter in filters if file_filter.name == answer.type_name), None) diff --git a/tests/suite/screens/holds.py b/tests/suite/screens/holds.py index 47333d081..54c25fae6 100644 --- a/tests/suite/screens/holds.py +++ b/tests/suite/screens/holds.py @@ -1,19 +1,22 @@ +import itertools import threading import time from dataclasses import dataclass from functools import partial from pathlib import Path -from typing import AbstractSet, Final, Iterator, List, Optional, Protocol, Sequence +from typing import AbstractSet, Callable, Final, Iterator, List, Optional, Protocol, Sequence import pytest import sampletones_application.logic.main.sources.scan as scan_module import sampletones_core.reconstructions.converter.converter as converter_module +from sampletones_application.services.export.reporter import ExportProgressReporter from sampletones_application.services.regeneration.service import RegenerationService -from sampletones_application.services.result import ServiceError +from sampletones_application.services.result import ServiceError, ServiceProgress from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features +from sampletones_core.exports.stage import ExportStage from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.progress import STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, announce from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -23,6 +26,7 @@ RELEASE_POLL_SECONDS: Final[float] = 0.05 HELD_ENTRY: Final[str] = "held-entry.txt" +FIRST_REPORT: Final[int] = 0 class Hold(Protocol): @@ -226,3 +230,66 @@ def _wait(self) -> None: self._released.wait() with self._lock: self._waiting -= 1 + + +class ExportHold: + """Holds every export at one report of its stages, until the scenario releases it. + + A run reports each stage it reaches and then asks whether it is still wanted. The hold answers + that question at the report it stands at, once the export's window has heard the stage, so the + window shows the run under way. While held, the run still hears a cancel and unwinds the way a + cancel unwinds a real run. Released, every run goes on to its end until the scenario holds again. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._held_report = FIRST_REPORT + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + build = ExportProgressReporter.__init__ + + def held( + reporter: ExportProgressReporter, + emit: Callable[[ServiceProgress[ExportStage]], None], + withdrawn: Callable[[], bool], + ) -> None: + build(reporter, emit, self._gate(withdrawn)) + + monkeypatch.setattr(ExportProgressReporter, "__init__", held) + + def waiting(self) -> int: + """How many runs stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + self._released.set() + + def hold_at(self, report: int) -> None: + """Holds every run from now on at its report numbered ``report``, counted from 0, letting the earlier ones pass.""" + self._held_report = report + self._released.clear() + + def _gate(self, withdrawn: Callable[[], bool]) -> Callable[[], bool]: + """The question one run asks after each report, which waits at the held report until released or withdrawn.""" + reports = itertools.count() + + def asked() -> bool: + if next(reports) == self._held_report: + self._wait(withdrawn) + + return withdrawn() + + return asked + + def _wait(self, withdrawn: Callable[[], bool]) -> None: + with self._lock: + self._waiting += 1 + + while not self._released.wait(RELEASE_POLL_SECONDS) and not withdrawn(): + continue + + with self._lock: + self._waiting -= 1 diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index 9e61489f3..373ee0b7a 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -27,7 +27,14 @@ child_environment, display_backend, ) -from tests.suite.screens.holds import ConversionHold, Holds, RegenerationHold, ReleaseSignal, ScanHold +from tests.suite.screens.holds import ( + ConversionHold, + ExportHold, + Holds, + RegenerationHold, + ReleaseSignal, + ScanHold, +) from tests.suite.screens.paths import FAILURE_SCREENSHOT, SCREENS_DIRECTORY from tests.suite.screens.render_thread import QueueRenderThread from tests.suite.screens.screen import Screen @@ -203,6 +210,18 @@ def regeneration_hold( return hold +@pytest.fixture +def export_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ExportHold: + """Holds every export at its first report, until the scenario releases it.""" + hold = ExportHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + @pytest.fixture def screen_boundaries( output_device: OutputDevice, diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 2926b051f..758b2cc08 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Callable, Final, Iterator, Optional, Tuple, TypeVar, Union -from sampletones_application.categories.context import generator_label +from sampletones_application.categories.context import channel_label, generator_label from sampletones_application.categories.key.text import TextKeyTuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.tags.compose import compose_tag @@ -16,7 +16,7 @@ from sampletones_application.tags.main import TAG_MAIN_EXPLORER_TREE from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorName from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords @@ -44,6 +44,7 @@ from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.context_menu import ContextMenu from tests.suite.screens.views.display_settings import DisplaySettings +from tests.suite.screens.views.exports import Exports from tests.suite.screens.views.instructions import Instructions from tests.suite.screens.views.keyboard_settings import KeyboardSettings from tests.suite.screens.views.main import Main @@ -107,6 +108,7 @@ def __init__( self.reconstructions = Reconstructions(bridge, hand, self.menu) self.sequencer = Sequencer(bridge, hand, self.menu) self.instructions = Instructions(bridge, hand) + self.exports = Exports(bridge, hand, self.menu) self.error_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_ERROR) self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) @@ -129,6 +131,10 @@ def words(self, key: Union[str, TextKeyTuple]) -> str: """What the language file says under ``key``, which is what the application shows the user.""" return self._language[key] + def channel_words(self, channel: ChannelName) -> str: + """The name every display gives ``channel``.""" + return channel_label(self._language, channel) + def generator_words(self, generator: GeneratorName) -> str: """The words the application names ``generator`` by wherever it offers one.""" return generator_label(self._language, generator) @@ -231,6 +237,14 @@ def answer_next_dialog( """Answers the next file dialog of ``kind`` with ``path``, or dismisses it for ``None``.""" self._dialogs.answer(kind, path) + def answer_next_save_as( + self, + path: Path, + type_name: str, + ) -> None: + """Answers the next save dialog with ``path``, the offered file type named ``type_name`` picked in it.""" + self._dialogs.answer_save_as(path, type_name) + def claim_error(self, naming: str) -> None: """Waits for the error a scenario provoked on purpose, one whose message holds ``naming``. diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py index 6c8e14107..eaf1b035b 100644 --- a/tests/suite/screens/seeds.py +++ b/tests/suite/screens/seeds.py @@ -12,6 +12,11 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.features.envelope import Envelope from sampletones_core.fft import Window +from sampletones_core.formats.famitracker.instrument import instrument_to_fti_bytes +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.project import ProjectContainer @@ -20,6 +25,7 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document from tests.suite.library import build_served_library from tests.suite.performance import place_instrument @@ -46,6 +52,24 @@ ARRANGED_FRAMES: Final[int] = 30 PAD_VOLUME: Final[Tuple[int, ...]] = (12, 8, 4) PAD_ARPEGGIO: Final[Tuple[int, ...]] = (0, 0, 0) +FIRST_FRAME: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +FIRST_ROW: Final[int] = 0 +TURNING_PULSE_PITCH: Final[int] = 40 +TURNING_PULSE_SPAN: Final[int] = 24 +TURNING_SECOND_PITCH: Final[int] = 30 +TURNING_SECOND_SPAN: Final[int] = 31 +TURNING_TRIANGLE_PITCH: Final[int] = 30 +TURNING_TRIANGLE_SPAN: Final[int] = 29 +TURNING_PERIODS: Final[int] = 16 +TURNING_LEVELS: Final[int] = 15 +TURNING_DUTY_CYCLES: Final[int] = 4 +TURNING_SECOND_LEVEL_STEP: Final[int] = 7 +TURNING_NOISE_LEVEL_STEP: Final[int] = 5 +TURNING_SECOND_DUTY_TICKS: Final[int] = 3 +LOUDEST_LEVEL: Final[int] = 15 +RELEASING_VOLUME: Final[Tuple[int, ...]] = (15, 8) +RELEASE_POINT: Final[int] = 1 class Damage(StrEnum): @@ -199,7 +223,7 @@ class ArrangedProject: ``line`` is a sample sounding Pulse 1, the triangle and the noise; ``bass`` a sample sounding the triangle alone; ``pad`` a hand-written voice fading on Pulse 2, its arpeggio flat. The first starts the pattern on Pulse 1, the hand-written voice comes in on Pulse 2 at ``pad_row``, and the bass on the triangle at - ``bass_row``. + ``bass_row``. The order plays that pattern ``order_frames`` times. """ destination: Path @@ -208,13 +232,14 @@ class ArrangedProject: pad: str pad_row: int bass_row: int + order_frames: int def write(self) -> None: line_instructions = _playable_instructions(ARRANGED_FRAMES) bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} project = Project.create() - line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions)) - bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions)) + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, Config())) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, Config())) pad = Instrument( name=self.pad, envelopes=InstrumentEnvelopes( @@ -228,15 +253,178 @@ def write(self) -> None: place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=line) place_instrument(project, channel_name=ChannelName.PULSE2, row_index=self.pad_row, sample=pad) place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=self.bass_row, sample=bass) + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class OverlongProject: + """A project whose song changes on every channel at every tick, through ``order_frames`` frames. + + Its one sample, ``sample``, plays a new value on each channel at every tick for as long as a + frame lasts, and every frame of the order plays it from the top. Stored tick by tick, the song + outgrows the room an NSF program has; stored with each repeat saved once, it fits. + """ + + destination: Path + sample: str + order_frames: int + + def write(self) -> None: + project = Project.create() + ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(ONE_FRAME) + sample = Sample( + name=self.sample, reconstruction=_detached_reconstruction(_turning_instructions(ticks), Config()) + ) + project.voices.append(sample) + for channel in ChannelName.items(): + place_instrument(project, channel_name=channel, row_index=FIRST_ROW, sample=sample) + + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class TwoTuningsProject: + """A project whose sample ``line`` was converted at the default tuning, and ``bass`` with A4 at ``a4_frequency`` hertz. + + The line starts the first pattern on Pulse 1 and the bass the triangle, so the song plays both. + """ + + destination: Path + line: str + bass: str + a4_frequency: float + + def write(self) -> None: + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + default = Config() + retuned = default.model_copy( + update={"library": default.library.model_copy(update={"a4_frequency": self.a4_frequency})} + ) + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, default)) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, retuned)) + for voice in (line, bass): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=line) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=FIRST_ROW, sample=bass) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class LongEnvelopeProject: + """A project holding two hand-written voices whose volume envelopes run long, both placed on the first pattern. + + ``long`` fades through ``long_items`` items and ``middling`` through ``middling_items``, neither + of them ending in silence. + """ + + destination: Path + long: str + long_items: int + middling: str + middling_items: int + + def write(self) -> None: + project = Project.create() + long = _fading_instrument(self.long, self.long_items) + middling = _fading_instrument(self.middling, self.middling_items) + for voice in (long, middling): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=long) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=FIRST_ROW, sample=middling) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class ReleasingInstrumentFile: + """A FamiTracker instrument file of the voice ``name``, whose volume sequence states a release point.""" + + destination: Path + name: str + + def write(self) -> None: + sequences = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind} + sequences[SequenceKind.VOLUME] = InstrumentSequence( + kind=SequenceKind.VOLUME, + items=RELEASING_VOLUME, + release_point=RELEASE_POINT, + ) + instrument = Instrument2A03(index=STANDALONE_INSTRUMENT_INDEX, name=self.name, sequences=sequences) self.destination.parent.mkdir(parents=True, exist_ok=True) - ProjectContainer.save(project, self.destination) + self.destination.write_bytes(instrument_to_fti_bytes(instrument)) + + +def _fading_instrument(name: str, items: int) -> Instrument: + """A hand-written voice whose volume falls from the loudest level to the quietest one before silence, over and over.""" + return Instrument( + name=name, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=tuple(LOUDEST_LEVEL - index % TURNING_LEVELS for index in range(items))), + ), + ) -def _detached_reconstruction(instructions: Dict[ChannelName, List[InstructionUnion]]) -> Reconstruction: +def _turning_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]: + """Every channel sounding a new pitch, level or period at each of ``ticks`` ticks.""" + return { + ChannelName.PULSE1: [ + PulseInstruction( + on=True, + pitch=TURNING_PULSE_PITCH + tick % TURNING_PULSE_SPAN, + volume=1 + tick % TURNING_LEVELS, + duty_cycle=tick % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.PULSE2: [ + PulseInstruction( + on=True, + pitch=TURNING_SECOND_PITCH + tick % TURNING_SECOND_SPAN, + volume=1 + (tick * TURNING_SECOND_LEVEL_STEP) % TURNING_LEVELS, + duty_cycle=(tick // TURNING_SECOND_DUTY_TICKS) % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.TRIANGLE: [ + TriangleInstruction(on=True, pitch=TURNING_TRIANGLE_PITCH + tick % TURNING_TRIANGLE_SPAN) + for tick in range(ticks) + ], + ChannelName.NOISE: [ + NoiseInstruction( + on=True, + period=tick % TURNING_PERIODS, + volume=1 + (tick * TURNING_NOISE_LEVEL_STEP) % TURNING_LEVELS, + short=bool(tick % 2), + ) + for tick in range(ticks) + ], + } + + +def _repeat_first_frame(project: Project, order_frames: int) -> None: + """Lays ``order_frames`` frames in the order, each playing the patterns the first one plays.""" + for _ in range(order_frames - 1): + project.song.duplicate_frame(FIRST_FRAME) + + +def _save_project(project: Project, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, destination) + + +def _detached_reconstruction( + instructions: Dict[ChannelName, List[InstructionUnion]], + config: Config, +) -> Reconstruction: """A reconstruction of ``instructions`` naming no recording, the way a project stores a sample.""" return Reconstruction.create( instructions=instructions, - config=Config(), + config=config, coefficient=1.0, audio_filepath=(), stems_data=single_entry_stems_data(list(instructions), instructions), diff --git a/tests/suite/screens/steps/sequencer.py b/tests/suite/screens/steps/sequencer.py index d733fd618..100c94f43 100644 --- a/tests/suite/screens/steps/sequencer.py +++ b/tests/suite/screens/steps/sequencer.py @@ -2,10 +2,12 @@ from typing import Final from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from tests.suite.screens.dearpygui.items import Item from tests.suite.screens.screen import Screen EDIT_VOICE: Final[str] = "sequencer.voices.label.context_edit" +HOVER_RACE: Final[str] = "Error executing callback _on_row_hovered" def voice_row(screen: Screen, name: str) -> Item: @@ -20,9 +22,30 @@ def double_click_voice(screen: Screen, name: str) -> None: screen.sequencer.voices.edit(voice_row(screen, name)) +def open_voice_menu(screen: Screen, name: str) -> None: + """Right-clicks the row of the voice ``name``, which opens its menu.""" + screen.sequencer.voices.right_click(voice_row(screen, name)) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {name}") + + def open_voice(screen: Screen, name: str) -> None: """Opens the voice ``name`` on the Reconstructions tab through Edit on its row's menu.""" - row = voice_row(screen, name) - screen.sequencer.voices.right_click(row) - screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {name}") + open_voice_menu(screen, name) screen.context_menu.choose(screen.words(EDIT_VOICE)) + + +def forgive_the_hover_race(screen: Screen) -> None: + """Forgives the error a voice row's hover meets once the list has rebuilt the row, which the ledger records.""" + screen.forgive_known_error(HOVER_RACE) + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, letting the changed project go at the question about it.""" + forgive_the_hover_race(screen) + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() diff --git a/tests/suite/screens/views/context_menu.py b/tests/suite/screens/views/context_menu.py index 5360d3fb3..90170bb7e 100644 --- a/tests/suite/screens/views/context_menu.py +++ b/tests/suite/screens/views/context_menu.py @@ -3,6 +3,7 @@ import dearpygui.dearpygui as dpg from sampletones_application.tags.general import TAG_GLOBAL_CONTEXT_WINDOW, TAG_GLOBAL_TAB_INSTRUCTIONS +from sampletones_application.utils.callbacks.queue import CallbackQueue from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point, Rect from tests.suite.screens.dearpygui.hand import Hand @@ -10,14 +11,18 @@ MENU_ITEM_TYPE, MENU_TYPE, EntryReading, + Item, + find_labelled, read_item, read_label, read_popup_entries, ) +from tests.suite.screens.dearpygui.semantic import invoke ENTRY_INSET: Final[Point] = Point(x=12, y=8) CLEAR_OF_THE_TABS: Final[int] = 48 ENABLED: Final[str] = "enabled" +OPENING_FRAMES: Final[int] = 3 class MissingEntryError(AssertionError): @@ -57,6 +62,18 @@ def entries(self) -> List[EntryReading]: def labels(self) -> List[str]: return [entry.label for entry in self.entries()] + def submenus(self) -> List[str]: + """The labels of the submenus the menu offers, in the order shown.""" + + def read() -> List[str]: + return [ + read_label(child) + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1) + if dpg.get_item_info(child)["type"] == MENU_TYPE + ] + + return self._bridge.ask(read) + def submenu(self, label: str) -> List[Tuple[str, bool]]: """The entries of the submenu reading ``label``, each with whether it answers. @@ -81,6 +98,27 @@ def read() -> Optional[List[Tuple[str, bool]]]: return found + def choose_in( + self, + menu_label: str, + entry_label: str, + ) -> None: + """Opens the submenu reading ``menu_label`` with a click on it, and chooses its entry reading ``entry_label``. + + DearPyGui reports where a submenu's entries stand inside a window of its own that it names + nowhere, so the entry is chosen by running its callback the way a click does. The menu is then + put away by a click beside it, as a click on the entry would put it away. + + Raises: + MissingEntryError: If the menu offers no such submenu. + """ + header = self._submenu_header(menu_label) + self._hand.click_at(Point(x=header.x + ENTRY_INSET.x, y=header.y + ENTRY_INSET.y)) + self._bridge.frames(OPENING_FRAMES) + self._bridge.ask(lambda: invoke(self._submenu_entry(menu_label, entry_label), CallbackQueue.run)) + if self.is_shown(): + self.dismiss() + def choose(self, label: str) -> None: """Clicks the entry reading ``label``. @@ -94,6 +132,33 @@ def choose(self, label: str) -> None: raise MissingEntryError(f"The context menu offers no '{label}': it offers {self.labels()}") + def _submenu_header(self, label: str) -> Point: + """Where the submenu reading ``label`` stands in the viewport.""" + + def read() -> Optional[Point]: + corner = dpg.get_item_pos(TAG_GLOBAL_CONTEXT_WINDOW) + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1): + if dpg.get_item_info(child)["type"] == MENU_TYPE and read_label(child) == label: + position = dpg.get_item_state(child)["pos"] + return Point(x=round(corner[0] + position[0]), y=round(corner[1] + position[1])) + + return None + + found = self._bridge.ask(read) + if found is None: + raise MissingEntryError(f"The context menu offers no submenu '{label}'") + + return found + + @staticmethod + def _submenu_entry( + menu_label: str, + entry_label: str, + ) -> Item: + """The entry reading ``entry_label`` of the submenu reading ``menu_label``. Runs on the render thread.""" + menu = find_labelled(TAG_GLOBAL_CONTEXT_WINDOW, menu_label, item_type=MENU_TYPE) + return find_labelled(menu, entry_label, item_type=MENU_ITEM_TYPE) + def dismiss(self) -> None: """Clicks the bare stretch of the tab bar past the last tab, which puts the menu away.""" rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS)).rect diff --git a/tests/suite/screens/views/exports.py b/tests/suite/screens/views/exports.py new file mode 100644 index 000000000..770dcddff --- /dev/null +++ b/tests/suite/screens/views/exports.py @@ -0,0 +1,189 @@ +from typing import Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + TAG_GLOBAL_DIALOG_PATH_MESSAGE, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + TAG_SETTINGS_EXPORT_GROUP_STAGES, + TAG_SETTINGS_EXPORT_WINDOW, + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_WINDOW, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.items import read_item, read_shown_texts, read_value +from tests.suite.screens.dearpygui.semantic import choose +from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.notices import Notice + + +class MissingControlError(AssertionError): + """Raised when a scenario presses a control the screen draws nowhere.""" + + +class ExportProgress: + """The window an export holds the screen with while it runs: the stages it reached, and Cancel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_EXPORT_WINDOW)).shown + + def stages(self) -> Tuple[str, ...]: + """The stages the window lists, in the order the run reached them.""" + return self._bridge.ask(lambda: read_shown_texts(TAG_SETTINGS_EXPORT_GROUP_STAGES)) + + def cancel(self) -> None: + self._hand.click(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON)) + + +class NSFWindow: + """The window setting an NSF program up: its channels, its repeat, its level of compression, its title and file.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_WINDOW)).shown + + def offers(self, channel: ChannelName) -> bool: + """Whether ``channel``'s box answers a press, which it does for a channel the source sounds.""" + return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled + + def is_ticked(self, channel: ChannelName) -> bool: + return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) + + def tick(self, channel: ChannelName) -> None: + """Clicks ``channel``'s box, which ticks it or lets it go.""" + self._hand.click(_channel_box(channel)) + + def press_on(self, channel: ChannelName) -> None: + """Clicks where ``channel``'s box stands, whether or not the box answers.""" + rect = self._bridge.ask(lambda: read_item(_channel_box(channel))).rect + if rect is None: + raise MissingControlError(f"The box of {channel} stands nowhere") + + self._hand.click_at(rect.center) + + def says_a_channel_is_needed(self) -> bool: + """Whether the line asking for at least one channel stands in the window.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)).shown + + def can_export(self) -> bool: + return self._bridge.ask(lambda: read_item(_export_button())).enabled + + def press_export(self) -> None: + """Clicks where Export stands, whether or not it answers.""" + rect = self._bridge.ask(lambda: read_item(_export_button())).rect + if rect is None: + raise MissingControlError("Export stands nowhere") + + self._hand.click_at(rect.center) + + def export(self) -> None: + self._hand.click(_export_button()) + + def browse(self) -> None: + """Clicks Browse..., which asks for the file the program is written to.""" + self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_BROWSE, SUF_BUTTON)) + + def cancel(self) -> None: + self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_CANCEL, SUF_BUTTON)) + + def repeat(self) -> str: + """The way of repeating the Repeat list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_COMBO_REPEAT))) + + def choose_repeat(self, label: str) -> None: + """Picks the way of repeating reading ``label`` in the Repeat list.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_REPEAT, label, CallbackQueue.run)) + + def level(self) -> str: + """The level of compression the Level list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_COMBO_SCHEME))) + + def choose_level(self, label: str) -> None: + """Picks the level of compression reading ``label`` in the Level list.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_SCHEME, label, CallbackQueue.run)) + + def loop_frame(self) -> str: + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME))) + + def type_loop_frame(self, frame: str) -> None: + """Types ``frame`` in hexadecimal into the Frame field, in place of what it held.""" + self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, frame) + + def title(self) -> str: + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_TITLE))) + + def type_title(self, title: str) -> None: + """Types ``title`` into the Title field, in place of what it held.""" + self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_TITLE, title) + + +class Exports: + """The ways a project, a reconstruction or an instrument leaves the application as a file, and what they report. + + A project is exported from File โ–ธ Export, a reconstruction's channels from Reconstruction โ–ธ + Export instruments. An export runs under its window, and its outcome stands in a notice: the + project's names the format it was written in, and an instrument's names the file it wrote. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + self.progress = ExportProgress(bridge, hand) + self.nsf = NSFWindow(bridge, hand) + self.project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_MODULE_EXPORTED) + self.file_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_PATH_MESSAGE) + + def export_project(self, item: MenuElements) -> None: + """Chooses File โ–ธ Export โ–ธ ``item``.""" + self._menu.choose(MenuElements.GROUP_FILE, item) + + def export_reconstruction(self, item: MenuElements) -> None: + """Chooses Reconstruction โ–ธ Export instruments โ–ธ ``item``.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, item) + + def is_offered(self, group: MenuElements, item: MenuElements) -> bool: + """Whether the entry ``item`` of the menu ``group`` answers a press.""" + return self._menu.is_enabled(group, item) + + +def _channel_box(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) + + +def _export_button() -> str: + return compose_tag(TAG_SETTINGS_NSF_BUTTON_EXPORT, SUF_BUTTON) diff --git a/tests/suite/screens/views/instruments.py b/tests/suite/screens/views/instruments.py index 98e3d3b14..15e701993 100644 --- a/tests/suite/screens/views/instruments.py +++ b/tests/suite/screens/views/instruments.py @@ -14,7 +14,7 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_selected_tab, read_theme, read_value +from tests.suite.screens.dearpygui.items import read_item, read_label, read_selected_tab, read_theme, read_value from tests.suite.screens.dearpygui.keys import IMGUI_ENTER from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.bar_graph import BarGraph @@ -41,6 +41,10 @@ def front(self) -> str: """The tag of the tab standing in front.""" return self._bridge.ask(lambda: read_selected_tab(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR)) + def tab_label(self, channel: ChannelName) -> str: + """What ``channel``'s tab reads, which names the open hand-written voice on the tab it is edited under.""" + return self._bridge.ask(lambda: read_label(tab(channel))) + def tab_shown(self, channel: ChannelName) -> bool: return self._bridge.ask(lambda: read_item(tab(channel))).shown @@ -75,6 +79,16 @@ def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str """The theme the field wears, which marks a sequence refused or one an export shortens.""" return self._bridge.ask(lambda: read_theme(self.field(channel, feature))) + def hover_field(self, channel: ChannelName, feature: FeatureKey) -> None: + """Rests the pointer on ``channel``'s ``feature`` field, which puts what the field takes on the status bar.""" + field = self.field(channel, feature) + self._hand.scroll_into_view(field) + rect = self._bridge.ask(lambda: read_item(field).rect) + if rect is None: + raise LookupError(f"The field '{field}' stands nowhere") + + self._hand.move_to(rect.center) + def type_envelope(self, channel: ChannelName, feature: FeatureKey, sequence: str) -> None: """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses Enter.""" self.bring_forward(channel) diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py index 484a3a49a..f7cda28ca 100644 --- a/tests/suite/screens/views/sequencer.py +++ b/tests/suite/screens/views/sequencer.py @@ -4,6 +4,8 @@ from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.tags.general import ( + TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP, ) @@ -16,16 +18,19 @@ TAG_SEQUENCER_VOICES_DIALOG_REMOVE, TAG_SEQUENCER_VOICES_PANEL, TAG_SEQUENCER_VOICES_TABLE, + TAG_SEQUENCER_VOICES_WINDOW, ) from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_table +from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_region_view, read_table from tests.suite.screens.dearpygui.keys import IMGUI_ENTER from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.history import History from tests.suite.screens.views.main import Card from tests.suite.screens.views.menus import MenuBar +from tests.suite.screens.views.notices import Notice from tests.suite.screens.views.prompts import Prompt from tests.suite.screens.views.tracker import Tracker @@ -36,6 +41,7 @@ FIRST_CHANNEL_ORDER_ROW: Final[int] = 2 ORDER_LABEL_CELLS: Final[int] = 1 CELL_CONTENT: Final[int] = 0 +CLEAR_OF_THE_EDGE: Final[int] = 12 class Voices: @@ -49,6 +55,8 @@ def __init__( self._bridge = bridge self._hand = hand self.remove_prompt = Prompt(bridge, hand, TAG_SEQUENCER_VOICES_DIALOG_REMOVE) + self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) + self.imported_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED) self.card = Card(bridge, hand, TAG_SEQUENCER_VOICES_PANEL) def kind_color(self, name: str) -> Tuple[float, ...]: @@ -100,6 +108,14 @@ def right_click(self, row: Item) -> None: self._hand.scroll_into_view(row) self._hand.right_click(row) + def right_click_below_the_rows(self) -> None: + """Clicks the empty foot of the list with the right button, which opens the list's own menu.""" + view = self._bridge.ask(lambda: read_region_view(TAG_SEQUENCER_VOICES_WINDOW)) + if view is None: + raise LookupError("The list of voices stands nowhere") + + self._hand.right_click_at(Point(x=view.center.x, y=round(view.y + view.height) - CLEAR_OF_THE_EDGE)) + class Playback: """Song playback as the Playback menu offers it: Play, which reads Pause while a song plays, and Stop.""" diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py index 6a5160263..a9852c97e 100644 --- a/tests/suite/screens/world.py +++ b/tests/suite/screens/world.py @@ -9,15 +9,20 @@ from sampletones_application.config.session.state.state import ApplicationState from sampletones_application.config.session.state.window import ViewportState from sampletones_core.configs import Config +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT from sampletones_shared.paths.user import CONFIG_PATH, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.screens.environment import SCREEN_SIZE from tests.suite.screens.seeds import ( ArrangedProject, + LongEnvelopeProject, MiniLibrary, + OverlongProject, PlayableReconstruction, Recording, + ReleasingInstrumentFile, StoredProject, StoredReconstruction, + TwoTuningsProject, stored_recording, ) @@ -46,6 +51,21 @@ PAD_ROW: Final[int] = 4 BASS_ROW: Final[int] = 8 TAKE_FREQUENCY: Final[float] = 220.0 +SINGLE_ORDER_FRAME: Final[int] = 1 +LOOPING_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Looping.stp" +LOOPING_ORDER_FRAMES: Final[int] = 2 +OVERLONG_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Overlong.stp" +OVERLONG_SAMPLE: Final[str] = "Turning" +OVERLONG_ORDER_FRAMES: Final[int] = 12 +TWO_TUNINGS_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Tunings.stp" +RETUNED_A4: Final[float] = 432.0 +LONG_ENVELOPES_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Envelopes.stp" +LONG_VOICE: Final[str] = "Long" +LONG_ITEMS: Final[int] = 600 +MIDDLING_VOICE: Final[str] = "Middling" +MIDDLING_ITEMS: Final[int] = 300 +INSTRUMENTS_FOLDER: Final[str] = "instruments" +RELEASING_INSTRUMENT: Final[str] = "Releasing" class HomeFile(Protocol): @@ -206,6 +226,57 @@ def sequencer_world() -> World: config=None, files=( *playing.files, - ArrangedProject(ARRANGED_PROJECT, line=LINE, bass=BASS_VOICE, pad=PAD, pad_row=PAD_ROW, bass_row=BASS_ROW), + arranged_project(ARRANGED_PROJECT, SINGLE_ORDER_FRAME), ), ) + + +def exporting_world() -> World: + """The sequencer's home, holding besides the projects an export meets at its limits. + + :data:`LOOPING_PROJECT` is :data:`ARRANGED_PROJECT` with its pattern played twice in the order. + :data:`OVERLONG_PROJECT` changes every channel at every tick through :data:`OVERLONG_ORDER_FRAMES` + frames. :data:`TWO_TUNINGS_PROJECT` holds :data:`LINE` at the default tuning and + :data:`BASS_VOICE` with A4 at :data:`RETUNED_A4` hertz. :data:`LONG_ENVELOPES_PROJECT` holds + :data:`LONG_VOICE` and :data:`MIDDLING_VOICE`, whose volume envelopes run :data:`LONG_ITEMS` and + :data:`MIDDLING_ITEMS` items. The home's :data:`INSTRUMENTS_FOLDER` holds the FamiTracker instrument + :data:`RELEASING_INSTRUMENT`, whose volume states a release point. + """ + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=None, + config=None, + files=( + *sequencer.files, + arranged_project(LOOPING_PROJECT, LOOPING_ORDER_FRAMES), + OverlongProject(OVERLONG_PROJECT, sample=OVERLONG_SAMPLE, order_frames=OVERLONG_ORDER_FRAMES), + TwoTuningsProject(TWO_TUNINGS_PROJECT, line=LINE, bass=BASS_VOICE, a4_frequency=RETUNED_A4), + LongEnvelopeProject( + LONG_ENVELOPES_PROJECT, + long=LONG_VOICE, + long_items=LONG_ITEMS, + middling=MIDDLING_VOICE, + middling_items=MIDDLING_ITEMS, + ), + ReleasingInstrumentFile(releasing_instrument(), name=RELEASING_INSTRUMENT), + ), + ) + + +def releasing_instrument() -> Path: + """Where the home holds the FamiTracker instrument :data:`RELEASING_INSTRUMENT`.""" + return Path.cwd() / INSTRUMENTS_FOLDER / f"{RELEASING_INSTRUMENT}{EXT_FILE_INSTRUMENT}" + + +def arranged_project(destination: Path, order_frames: int) -> ArrangedProject: + """The arranged project laid at ``destination``, its pattern played ``order_frames`` times in the order.""" + return ArrangedProject( + destination, + line=LINE, + bass=BASS_VOICE, + pad=PAD, + pad_row=PAD_ROW, + bass_row=BASS_ROW, + order_frames=order_frames, + ) From 0ac66aa28feacba9e445cb9d59bc1cf24b444af1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 19:49:31 +0200 Subject: [PATCH 13/19] Proved: palettes, Display settings, fullscreen, dialogs, menus, keybindings, shortcuts and cards on a live screen --- docs/development/application/screens.md | 6 + docs/development/bugs-and-todos.md | 4 + tests/screens/interface/__init__.py | 0 tests/screens/interface/conftest.py | 9 + tests/screens/interface/test_cards.py | 109 ++++++++ tests/screens/interface/test_dialog_sweep.py | 148 ++++++++++ tests/screens/interface/test_display.py | 228 ++++++++++++++++ tests/screens/interface/test_fullscreen.py | 64 +++++ tests/screens/interface/test_keybindings.py | 135 ++++++++++ tests/screens/interface/test_menus.py | 135 ++++++++++ tests/screens/interface/test_palettes.py | 169 ++++++++++++ tests/screens/interface/test_shortcuts.py | 252 ++++++++++++++++++ tests/screens/sequencer/test_colors.py | 205 ++++++++++++++ tests/suite/screens/application.py | 2 + tests/suite/screens/boundaries/highlights.py | 111 ++++++++ tests/suite/screens/dearpygui/items.py | 77 +++++- tests/suite/screens/dearpygui/screenshot.py | 39 ++- tests/suite/screens/palettes.py | 36 +++ tests/suite/screens/plugin.py | 5 + tests/suite/screens/screen.py | 23 +- tests/suite/screens/views/display_settings.py | 37 +++ .../suite/screens/views/keyboard_settings.py | 5 + tests/suite/screens/views/main.py | 19 ++ tests/suite/screens/views/menus.py | 29 +- tests/suite/screens/views/tracker.py | 26 +- 25 files changed, 1858 insertions(+), 15 deletions(-) create mode 100644 tests/screens/interface/__init__.py create mode 100644 tests/screens/interface/conftest.py create mode 100644 tests/screens/interface/test_cards.py create mode 100644 tests/screens/interface/test_dialog_sweep.py create mode 100644 tests/screens/interface/test_display.py create mode 100644 tests/screens/interface/test_fullscreen.py create mode 100644 tests/screens/interface/test_keybindings.py create mode 100644 tests/screens/interface/test_menus.py create mode 100644 tests/screens/interface/test_palettes.py create mode 100644 tests/screens/interface/test_shortcuts.py create mode 100644 tests/screens/sequencer/test_colors.py create mode 100644 tests/suite/screens/boundaries/highlights.py create mode 100644 tests/suite/screens/palettes.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 52d6310f2..3cf80a898 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -150,6 +150,9 @@ A scenario speaks through views, one per tab, card and dialog, under `tests/suit knows its controls by their tags and its words by their language keys. A tag that moves changes one view, and a scenario reads as the gestures a user makes. +What DearPyGui reports no item for is read from the frame it draws: `screen.frame_pixels()` hands over the +next frame, which is how a menu popup's width is measured. + ## The boundaries - **File dialogs.** A native dialog stops the queue while it stands, so a scenario queues its answer before @@ -166,6 +169,9 @@ and a scenario reads as the gestures a user makes. runs, and `screen.revealed()` reads what it showed. - **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request a title bar's close button sends. +- **Table highlights.** DearPyGui keeps a table's row, cell and column highlights without a way to read + their color back, so the calls laying and lifting them are wrapped from the first frame on, and + `screen.table_highlights()` reads what stands. ## Holding work in flight diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index cf411c3c1..63ec3f990 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -222,3 +222,7 @@ currently out of line. An entry leaves when the code meets the contract again. can see: `voice_instrument` raises the tuning error inside the menu's callback, so no message, no save dialog and no file follow, while a Bitphase project or an NSF program of the same project stops with a message. `tests/screens/exports/test_progress.py` reproduces it as a known failure. +* Closing the window while Display settings holds a window size kept on the countdown but never confirmed + writes that size: leaving records the live window size, while the dialog keeps the session at the values + it opened with until OK, and the window manager's close passes the open dialog by. + `tests/screens/interface/test_display.py` reproduces it as a known failure. diff --git a/tests/screens/interface/__init__.py b/tests/screens/interface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/conftest.py b/tests/screens/interface/conftest.py new file mode 100644 index 000000000..2e1a82a3a --- /dev/null +++ b/tests/screens/interface/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.world import World, sequencer_world + + +@pytest.fixture +def world() -> World: + """The home every interface scenario starts in: an arranged project and reconstructions that play.""" + return sequencer_world() diff --git a/tests/screens/interface/test_cards.py b/tests/screens/interface/test_cards.py new file mode 100644 index 000000000..47ea01ad0 --- /dev/null +++ b/tests/screens/interface/test_cards.py @@ -0,0 +1,109 @@ +import operator +from typing import Dict, Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.compose import TAG_SEPARATOR +from sampletones_application.tags.general import SUF_COLLAPSE_STRIP +from sampletones_application.tags.sequencer import TAG_SEQUENCER_HISTORY_PANEL +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.main import Card +from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION + +FILL_CARDS: Final[Tuple[str, ...]] = (TAG_SEQUENCER_HISTORY_PANEL,) +TABS: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS) +SETTLING_FRAMES: Final[int] = 8 +STRIP_ENDING: Final[str] = f"{TAG_SEPARATOR}{SUF_COLLAPSE_STRIP}" + + +def cards_on(tab: Tab) -> List[str]: + """The tags of the cards shown on ``tab`` that fold. Runs on the render thread.""" + cards: List[str] = [] + for item in dpg.get_all_items(): + alias = str(dpg.get_item_alias(item)) + if alias.startswith(f"{tab}{TAG_SEPARATOR}") and alias.endswith(STRIP_ENDING): + card = alias[: -len(STRIP_ENDING)] + if read_item(card).shown: + cards.append(card) + + return sorted(cards) + + +def box(screen: Screen, item: str) -> Rect: + rect = screen.bridge.ask(lambda: read_item(item).rect) + assert rect is not None, f"{item} stands nowhere" + return rect + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +class TestEveryCardFoldsOntoItsBar: + """Every card folds onto its bar: a card folding down keeps the bar and the card's padding around it, at its top + edge or, for a card filling its column, at its bottom; a card folding sideways keeps its rail; unfolded, each + stands as it stood. + """ + + def test_each_card_on_each_tab(self, screen: Screen) -> None: + folded: Dict[str, List[str]] = {} + + def folding_down(card: Card) -> None: + before = box(screen, card.tag) + card.toggle() + screen.expect(card.is_collapsed, bool, description=f"{card.tag} folded") + screen.frames(SETTLING_FRAMES) + + collapsed = box(screen, card.tag) + strip = box(screen, card.strip) + padding = strip.y - collapsed.y + assert collapsed.height == strip.height + 2 * padding, f"{card.tag}: {collapsed} around {strip}" + if card.tag in FILL_CARDS: + assert collapsed.y + collapsed.height == before.y + before.height, f"{card.tag} left its bottom" + else: + assert collapsed.y == before.y, f"{card.tag} left its top" + + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description=f"{card.tag} unfolded") + screen.frames(SETTLING_FRAMES) + assert box(screen, card.tag) == before, f"{card.tag} came back elsewhere" + + def folding_sideways(card: Card) -> None: + before = box(screen, card.tag) + card.toggle() + screen.expect(card.is_collapsed, bool, description=f"{card.tag} folded") + screen.frames(SETTLING_FRAMES) + + collapsed = box(screen, card.tag) + rail = box(screen, card.rail) + assert collapsed.height == before.height, f"{card.tag} changed its height folding sideways" + assert collapsed.x <= rail.x and rail.x + rail.width <= collapsed.x + collapsed.width, f"{card.tag}" + assert collapsed.width < before.width + + card.unfold_from_the_rail() + screen.expect(card.is_collapsed, operator.not_, description=f"{card.tag} unfolded") + screen.frames(SETTLING_FRAMES) + assert box(screen, card.tag) == before, f"{card.tag} came back elsewhere" + + def fold_every_card(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in TABS: + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) + folded[tab] = screen.bridge.ask(lambda: cards_on(tab)) + assert folded[tab], f"no card folds on {tab}" + for tag in folded[tab]: + card = screen.main.card(tag) + if card.folds_sideways(): + folding_sideways(card) + else: + folding_down(card) + + screen.scenario(fold_every_card).run() diff --git a/tests/screens/interface/test_dialog_sweep.py b/tests/screens/interface/test_dialog_sweep.py new file mode 100644 index 000000000..d85a3d6e9 --- /dev/null +++ b/tests/screens/interface/test_dialog_sweep.py @@ -0,0 +1,148 @@ +from dataclasses import dataclass +from functools import partial +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_NSF_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import is_tag_within, read_viewport, read_windows +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.world import ARRANGED_PROJECT + +STILL_FRAMES: Final[int] = 20 +CENTER_TOLERANCE: Final[float] = 1.0 +HALF: Final[float] = 2.0 +SETTLING_DRAWN_FRAMES: Final[int] = 2 + + +@dataclass(frozen=True) +class MenuDialog: + """A dialog a menu entry opens, and the tag its window stands under.""" + + name: str + group: MenuElements + item: MenuElements + window: str + + +DIALOGS: Final[Tuple[MenuDialog, ...]] = ( + MenuDialog( + "Display settings", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, + TAG_SETTINGS_DISPLAY_WINDOW, + ), + MenuDialog( + "Keyboard shortcuts", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + ), + MenuDialog( + "Audio settings", + MenuElements.GROUP_PLAYBACK, + MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, + TAG_SETTINGS_AUDIO_WINDOW, + ), + MenuDialog( + "Project properties", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_PROJECT_PROPERTIES, + TAG_SETTINGS_PROPERTIES_WINDOW, + ), + MenuDialog( + "Render song", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_RENDER_SONG, + TAG_SETTINGS_RENDER_WINDOW, + ), + MenuDialog( + "NSF program", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_EXPORT_NSF, + TAG_SETTINGS_NSF_WINDOW, + ), + MenuDialog( + "About", + MenuElements.GROUP_HELP, + MenuElements.ITEM_HELP_ABOUT, + TAG_GLOBAL_DIALOG_ABOUT, + ), +) + + +def dialog_box(window: str) -> Optional[Rect]: + """The box of the shown window standing under ``window``, if one stands. Runs on the render thread.""" + return next( + (reading.rect for reading in read_windows() if reading.shown and is_tag_within(reading.alias, window)), + None, + ) + + +def is_centered(box: Rect, viewport: Rect) -> bool: + return ( + abs(box.x + box.width / HALF - viewport.width / HALF) <= CENTER_TOLERANCE + and abs(box.y + box.height / HALF - viewport.height / HALF) <= CENTER_TOLERANCE + ) + + +def is_inside(box: Rect, viewport: Rect) -> bool: + return box.x >= 0 and box.y >= 0 and box.x + box.width <= viewport.width and box.y + box.height <= viewport.height + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestEveryDialogOpensCentered: + """On its first open of the session, every dialog a menu opens stands centered in the window and inside it, in + one place on every frame from its third drawn frame on. + + A dialog whose content settles its height takes two frames to measure itself, as `dialogs.md` states, so the + rule holds from the frame after those. + """ + + def test_each_dialog_on_its_first_open(self, screen: Screen) -> None: + def opening(dialog: MenuDialog) -> None: + with screen.record(partial(dialog_box, dialog.window)) as recording: + screen.menu.choose(dialog.group, dialog.item) + screen.expect( + lambda: screen.bridge.ask(partial(dialog_box, dialog.window)) is not None, + bool, + description=dialog.name, + ) + screen.frames(STILL_FRAMES) + + boxes: List[Rect] = [box for box in recording.values() if box is not None][SETTLING_DRAWN_FRAMES:] + viewport = screen.bridge.ask(read_viewport) + assert boxes, f"{dialog.name} was drawn for {SETTLING_DRAWN_FRAMES} frames or fewer" + assert all(box == boxes[0] for box in boxes), f"{dialog.name} moved: {boxes}" + assert is_inside(boxes[0], viewport), f"{dialog.name} at {boxes[0]} in {viewport}" + assert is_centered(boxes[0], viewport), f"{dialog.name} at {boxes[0]} in {viewport}" + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.expect( + lambda: screen.bridge.ask(partial(dialog_box, dialog.window)) is None, + bool, + description=f"{dialog.name} closed", + ) + + def open_each_dialog_once(screen: Screen) -> None: + for dialog in DIALOGS: + opening(dialog) + + screen.scenario(open_each_dialog_once).run() diff --git a/tests/screens/interface/test_display.py b/tests/screens/interface/test_display.py new file mode 100644 index 000000000..83b17c05a --- /dev/null +++ b/tests/screens/interface/test_display.py @@ -0,0 +1,228 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.display import DisplayConfig +from sampletones_application.config.session.state.window import ViewportState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.display import Resolution +from tests.suite.screens.dearpygui.items import read_viewport, read_viewport_decorated +from tests.suite.screens.screen import Screen +from tests.suite.screens.world import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_state + +SIZE_SEPARATOR: Final[str] = "x" +SEEDED_SIZE: Final[Resolution] = Resolution(width=1280, height=800) +COUNTDOWN_LIMIT_SECONDS: Final[float] = 20.0 +SETTLING_FRAMES: Final[int] = 10 + + +def size_named(label: str) -> Resolution: + """The window size a Resolution entry names, written width first.""" + width, height = label.split(SIZE_SEPARATOR) + return Resolution(width=int(width), height=int(height)) + + +def window_size(screen: Screen) -> Tuple[int, int]: + viewport = screen.bridge.ask(read_viewport) + return round(viewport.width), round(viewport.height) + + +def framed(screen: Screen) -> bool: + return screen.bridge.ask(read_viewport_decorated) + + +def open_display_settings(screen: Screen) -> None: + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + +def another_size(screen: Screen) -> str: + """A size the Resolution list offers other than the one it names.""" + settings = screen.display_settings + current = settings.resolution() + return next(label for label in settings.resolutions() if label != current) + + +def kept(screen: Screen) -> None: + """Waits for the countdown a window change starts, and presses Keep.""" + settings = screen.display_settings + screen.expect(settings.countdown_shown, bool, description="the countdown") + settings.keep() + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + + +def leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestLeavingWritesTheWindowMode: + """A window size and a frameless window, kept on the countdown and confirmed with OK, are what leaving writes.""" + + def test_the_size_and_the_frame_are_written(self, screen: Screen) -> None: + settings = screen.display_settings + chosen: List[Resolution] = [] + + def pick_a_size_and_keep_it(screen: Screen) -> None: + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + chosen.append(size_named(label)) + screen.expect( + lambda: window_size(screen), + (chosen[0].width, chosen[0].height).__eq__, + description="the window at the size picked", + ) + + def go_frameless_and_keep_it(screen: Screen) -> None: + assert framed(screen) + + settings.toggle_borderless() + kept(screen) + + screen.expect(lambda: framed(screen), operator.not_, description="the window without its frame") + + def confirm_and_leave(screen: Screen) -> None: + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + leave(screen) + + viewport = written_state().viewport + assert (viewport.width, viewport.height) == (chosen[0].width, chosen[0].height) + assert written_application_config().display.borderless + + screen.scenario(pick_a_size_and_keep_it, go_frameless_and_keep_it, confirm_and_leave).run() + + +class TestAHomeHoldingAWindowModeShowsIt: + """A home whose session holds a window size and whose settings ask for a frameless window without vsync opens + the application that way, and Display settings names it. + """ + + @pytest.fixture + def world(self) -> World: + state = screen_filling_state().model_copy( + update={"viewport": ViewportState(width=SEEDED_SIZE.width, height=SEEDED_SIZE.height, x=0, y=0)} + ) + return World( + state=state, + application_config=ApplicationConfig(display=DisplayConfig(borderless=True, vsync=False)), + config=None, + files=(), + ) + + def test_the_window_opens_as_the_session_left_it(self, screen: Screen) -> None: + settings = screen.display_settings + + def the_window_stands_as_written(screen: Screen) -> None: + assert window_size(screen) == (SEEDED_SIZE.width, SEEDED_SIZE.height) + assert not framed(screen) + + def display_settings_names_it(screen: Screen) -> None: + open_display_settings(screen) + + assert settings.resolution() == str(SEEDED_SIZE) + assert settings.borderless() + assert not settings.vsync() + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(the_window_stands_as_written, display_settings_names_it).run() + + +class TestTheWindowModeCountdown: + """A window change counts down: Keep keeps it, while Revert, and the count running out, bring the window back; + Cancel then puts back the window the dialog opened with. + """ + + def test_keep_revert_and_running_out(self, screen: Screen) -> None: + settings = screen.display_settings + + def revert_brings_the_frame_back(screen: Screen) -> None: + open_display_settings(screen) + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not framed(screen) + + settings.revert() + + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def running_out_brings_it_back(screen: Screen) -> None: + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + + screen.bridge.expect( + settings.countdown_shown, + operator.not_, + description="the countdown run out", + timeout=COUNTDOWN_LIMIT_SECONDS, + ) + + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def keep_keeps_it_until_cancel(screen: Screen) -> None: + settings.toggle_borderless() + + kept(screen) + + screen.frames(SETTLING_FRAMES) + assert not framed(screen) + assert settings.borderless() + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.expect(lambda: framed(screen), bool, description="the frame back") + + screen.scenario(revert_brings_the_frame_back, running_out_brings_it_back, keep_keeps_it_until_cancel).run() + + +class TestClosingWithASizeKeptButNotConfirmed: + """The window closed while Display settings holds a size kept on the countdown but never confirmed: the session + keeps the size the dialog opened with, as it does for every setting the dialog has not committed. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: closing the window writes a size Display settings never confirmed", + ) + def test_leaving_writes_the_confirmed_size(self, screen: Screen) -> None: + settings = screen.display_settings + opened: List[Tuple[int, int]] = [] + + def keep_a_size_without_confirming_it(screen: Screen) -> None: + opened.append(window_size(screen)) + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + assert screen.wait_for_exit() + viewport = written_state().viewport + assert (viewport.width, viewport.height) == opened[0] + + screen.scenario(keep_a_size_without_confirming_it, close_the_window).run() diff --git a/tests/screens/interface/test_fullscreen.py b/tests/screens/interface/test_fullscreen.py new file mode 100644 index 000000000..e25700be7 --- /dev/null +++ b/tests/screens/interface/test_fullscreen.py @@ -0,0 +1,64 @@ +from typing import Final, List + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items import read_viewport +from tests.suite.screens.environment import SCREEN_SIZE +from tests.suite.screens.screen import Screen +from tests.suite.screens.written import written_state + +WHOLE_SCREEN: Final[Rect] = Rect(x=0, y=0, width=SCREEN_SIZE.width, height=SCREEN_SIZE.height) + + +def window(screen: Screen) -> Rect: + return screen.bridge.ask(read_viewport) + + +def marked_fullscreen(screen: Screen) -> bool: + """Whether View โ–ธ Fullscreen carries its check mark.""" + return screen.menu.is_checked(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_FULLSCREEN) + + +class TestFullscreen: + """F11 fills the screen and F11 again gives the window back its size and place; the View menu's mark and what + leaving writes follow the window. + """ + + def test_f11_fills_and_restores(self, screen: Screen) -> None: + windowed: List[Rect] = [] + + def f11_fills_the_screen(screen: Screen) -> None: + windowed.append(window(screen)) + assert windowed[0] != WHOLE_SCREEN + + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), WHOLE_SCREEN.__eq__, description="the window filling the screen") + assert marked_fullscreen(screen) + + def f11_again_restores_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), windowed[0].__eq__, description="the window back where it stood") + assert not marked_fullscreen(screen) + + def two_presses_at_once_end_where_they_began(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), windowed[0].__eq__, description="the window where it stood") + assert not marked_fullscreen(screen) + + def leaving_writes_a_window(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert not written_state().viewport.fullscreen + + screen.scenario( + f11_fills_the_screen, + f11_again_restores_it, + two_presses_at_once_end_where_they_began, + leaving_writes_a_window, + ).run() diff --git a/tests/screens/interface/test_keybindings.py b/tests/screens/interface/test_keybindings.py new file mode 100644 index 000000000..31932e82e --- /dev/null +++ b/tests/screens/interface/test_keybindings.py @@ -0,0 +1,135 @@ +import operator +from functools import partial +from typing import Dict, Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER, IMGUI_ESCAPE +from tests.suite.screens.keyboard import press_combination +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD +from tests.suite.screens.written import written_application_config + +LISTENING: Final[str] = "settings.keybindings.message.capturing" +NEW_UNDO: Final[KeyCombination] = KeyCombination.parse("Ctrl+Alt+U") +RENAMED: Final[str] = "Renamed" +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def rename_the_line(screen: Screen) -> None: + """Renames the line through F2 on its row, which the history records.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.pick(screen.expect_item(partial(voices.row, LINE), description="the line's row")) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) + screen.expect(voices.names, [RENAMED, BASS_VOICE, PAD].__eq__, description="the line renamed") + + +class TestRebindingUndo: + """Undo rebound: Escape cancels a capture, the new keys undo while the old ones do nothing, the menu prints the + new keys, keys another action holds ask before they are taken, and leaving writes the rebind. + """ + + def test_the_new_keys_undo(self, screen: Screen) -> None: + settings = screen.keyboard_settings + voices = screen.sequencer.voices + original: Dict[ShortcutId, str] = {} + + def escape_cancels_a_capture(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + original.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.UNDO) + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), screen.words(LISTENING).__eq__, description="listening" + ) + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + original[ShortcutId.UNDO].__eq__, + description="the keys back", + ) + assert settings.is_shown() + + def rebind_undo(screen: Screen) -> None: + settings.listen_for(ShortcutId.UNDO) + + press_combination(screen.hand, NEW_UNDO) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + NEW_UNDO.display().__eq__, + description="the new keys", + ) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + undo = next( + entry + for entry in screen.menu.entries(MenuElements.GROUP_EDIT) + if entry.label == screen.menu.label(MenuElements.ITEM_EDIT_UNDO) + ) + assert undo.keys == NEW_UNDO.display() + + def the_old_keys_do_nothing_and_the_new_undo(screen: Screen) -> None: + rename_the_line(screen) + + press_combination(screen.hand, KeyCombination.parse(original[ShortcutId.UNDO])) + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [RENAMED, BASS_VOICE, PAD] + press_combination(screen.hand, NEW_UNDO) + screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the rename undone") + + def keys_another_action_holds_ask_first(screen: Screen) -> None: + reassign = settings.reassign_prompt + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.UNDO) + + press_combination(screen.hand, KeyCombination.parse(original[ShortcutId.REDO])) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == original[ShortcutId.REDO] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + def leaving_writes_the_rebind(screen: Screen) -> None: + forgive_the_hover_race(screen) + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_application_config().shortcuts.overrides == {ShortcutId.UNDO.value: NEW_UNDO.display()} + + screen.scenario( + escape_cancels_a_capture, + rebind_undo, + the_old_keys_do_nothing_and_the_new_undo, + keys_another_action_holds_ask_first, + leaving_writes_the_rebind, + ).run() diff --git a/tests/screens/interface/test_menus.py b/tests/screens/interface/test_menus.py new file mode 100644 index 000000000..09292664a --- /dev/null +++ b/tests/screens/interface/test_menus.py @@ -0,0 +1,135 @@ +from typing import Final, List, Tuple + +import numpy as np +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.palettes import Color, shipped_palettes, token_color +from tests.suite.screens.screen import Screen +from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION + +POPUP_TOKEN: Final[str] = "popup" +RGB: Final[int] = 3 +COLOR_TOLERANCE: Final[float] = 1.5 / 255.0 +LEFT_INSET: Final[int] = 4 +OPENING_FRAMES: Final[int] = 5 +READINGS: Final[int] = 8 +FRAMES_BETWEEN_READINGS: Final[int] = 3 +MENUS: Final[Tuple[MenuElements, ...]] = ( + MenuElements.GROUP_FILE, + MenuElements.GROUP_EDIT, + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.GROUP_VOICE, + MenuElements.GROUP_PLAYBACK, + MenuElements.GROUP_VIEW, + MenuElements.GROUP_HELP, +) +PRINTED: Final[Tuple[Tuple[MenuElements, MenuElements, ShortcutId], ...]] = ( + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT, ShortcutId.NEW_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT, ShortcutId.OPEN_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT, ShortcutId.SAVE_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS, ShortcutId.SAVE_PROJECT_AS), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES, ShortcutId.PROJECT_PROPERTIES), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_RENDER_SONG, ShortcutId.RENDER_SONG), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_CLOSE_PROJECT, ShortcutId.CLOSE_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_EXIT, ShortcutId.EXIT), + (MenuElements.GROUP_EDIT, MenuElements.ITEM_EDIT_UNDO, ShortcutId.UNDO), + (MenuElements.GROUP_EDIT, MenuElements.ITEM_EDIT_REDO, ShortcutId.REDO), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_RECONSTRUCT_FILE, + ShortcutId.RECONSTRUCT_FILE, + ), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_RECONSTRUCT_DIRECTORY, + ShortcutId.RECONSTRUCT_DIRECTORY, + ), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_OPEN, ShortcutId.OPEN_RECONSTRUCTION), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE, ShortcutId.SAVE_RECONSTRUCTION), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_SAVE_AS, + ShortcutId.SAVE_RECONSTRUCTION_AS, + ), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE, ShortcutId.CLOSE_RECONSTRUCTION), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_EXPORT_WAV, + ShortcutId.EXPORT_RECONSTRUCTION_WAV, + ), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY_FROM_START, ShortcutId.PLAY_FROM_START), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY_FROM_FRAME, ShortcutId.PLAY_FROM_FRAME), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_STOP, ShortcutId.STOP), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY, ShortcutId.TOGGLE_AUTOPLAY), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG, ShortcutId.TOGGLE_LOOP_SONG), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, ShortcutId.AUDIO_SETTINGS), + ( + MenuElements.GROUP_PLAYBACK_CHANNELS, + MenuElements.ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, + ShortcutId.UNMUTE_ALL_CHANNELS, + ), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS, ShortcutId.TOGGLE_ADVANCED_SETTINGS), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_FULLSCREEN, ShortcutId.TOGGLE_FULLSCREEN), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, ShortcutId.DISPLAY_SETTINGS), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, ShortcutId.KEYBOARD_SETTINGS), +) + + +def popup_right_edge(pixels: np.ndarray, header: Point, background: Color) -> int: + """The last column the popup below ``header`` paints in its background, on the rows it spans. + + The popup's rows are those its background reaches just inside its left edge, under the header. + """ + painted = np.all(np.abs(pixels[:, :, :RGB] - np.array(background[:RGB])) <= COLOR_TOLERANCE, axis=2) + column = header.x + LEFT_INSET + rows = np.nonzero(painted[:, column])[0] + rows = rows[rows > header.y] + assert rows.size, f"no popup stands under the header at {header}" + columns = np.nonzero(painted[rows, column:].any(axis=0))[0] + return int(columns.max()) + column + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +class TestMenuPopupsHoldTheirWidth: + """Every menu popup keeps its width on every frame it stands open.""" + + def test_each_popup_stands_still(self, screen: Screen) -> None: + background = token_color(shipped_palettes().get(DEFAULT_PALETTE_NAME), POPUP_TOKEN) + + def measuring(group: MenuElements) -> None: + screen.menu.open(group) + screen.frames(OPENING_FRAMES) + header = screen.menu.header(group) + edges: List[int] = [] + for _ in range(READINGS): + edges.append(popup_right_edge(screen.frame_pixels(), header, background)) + screen.frames(FRAMES_BETWEEN_READINGS) + + assert len(set(edges)) == 1, f"the {group} popup widened: {edges}" + screen.menu.close() + screen.frames(OPENING_FRAMES) + + def open_each_menu(screen: Screen) -> None: + for group in MENUS: + measuring(group) + + screen.scenario(open_each_menu).run() + + +class TestPrintedKeysFollowTheScheme: + """Each menu entry prints the keys the scheme in place gives the action it runs.""" + + def test_each_entry_prints_its_keys(self, screen: Screen) -> None: + for group, item, shortcut_id in PRINTED: + entries = {entry.label: entry.keys for entry in screen.menu.entries(group)} + assert screen.menu.label(item) in entries, f"{group} offers no {item}: {list(entries)}" + assert entries[screen.menu.label(item)] == screen.shortcut_words(shortcut_id), f"{item}" diff --git a/tests/screens/interface/test_palettes.py b/tests/screens/interface/test_palettes.py new file mode 100644 index 000000000..0e866d27d --- /dev/null +++ b/tests/screens/interface/test_palettes.py @@ -0,0 +1,169 @@ +import operator +from dataclasses import dataclass +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.highlights import HighlightPlace +from tests.suite.screens.dearpygui.items import read_held_colors +from tests.suite.screens.palettes import Color, in_fractions, shared_colors, shipped_palettes +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION + +SETTLING_FRAMES: Final[int] = 10 +TAB_FRAMES: Final[int] = 10 +EVERY_TAB: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.INSTRUCTIONS, Tab.RECONSTRUCTIONS, Tab.SEQUENCER) +SECOND_ROUND: Final[int] = 2 +ROUNDS: Final[int] = 20 + + +@dataclass(frozen=True) +class Painted: + """Every color the interface holds at one moment: what DearPyGui holds for items and themes, and the highlights + laid on its tables. + """ + + held: Dict[str, Color] + highlights: Dict[HighlightPlace, Color] + + +def painted(screen: Screen) -> Painted: + return Painted( + held=screen.bridge.ask(read_held_colors), + highlights={place: in_fractions(color) for place, color in screen.table_highlights().items()}, + ) + + +def unchanged(readings: List[Painted]) -> Tuple[List[Color], List[Color]]: + """The held colors and the highlight colors that read the same in every reading.""" + held = [readings[0].held[key] for key in readings[0].held if len({reading.held[key] for reading in readings}) == 1] + highlights = [ + readings[0].highlights[place] + for place in readings[0].highlights + if len({reading.highlights[place] for reading in readings}) == 1 + ] + return held, highlights + + +def differing(after: Painted, before: Painted) -> Dict[str, Tuple[Color, Color]]: + """The held colors two readings disagree on, among the items both found drawn. + + A dialog closed between the readings takes its own items off the screen, so those are left out. + """ + common = after.held.keys() & before.held.keys() + assert len(common) > len(before.held) // 2 + return {key: (after.held[key], before.held[key]) for key in common if after.held[key] != before.held[key]} + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +def show_every_tab(screen: Screen) -> None: + """Brings each tab forward once, so every panel stands built and drawn before the colors are read.""" + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in EVERY_TAB: + screen.tabs.bring_to_front(tab) + screen.frames(TAB_FRAMES) + + +class TestEveryHeldColorFollowsThePalette: + """A palette picked repaints every color the interface holds: what no palette changes is a color every palette + states alike, the table highlights follow, a round trip comes back to where it began, and Cancel leaves nothing + of the palette it previewed. + """ + + def test_no_color_is_left_from_the_palette_before(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + readings: Dict[str, Painted] = {} + + def read_the_palette_the_application_starts_in(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + assert settings.palette() == DEFAULT_PALETTE_NAME + readings[DEFAULT_PALETTE_NAME] = painted(screen) + assert readings[DEFAULT_PALETTE_NAME].highlights + + def every_palette_repaints_what_differs(screen: Screen) -> None: + for name in catalog.names: + if name == DEFAULT_PALETTE_NAME: + continue + + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + readings[name] = painted(screen) + assert readings[name].held.keys() == readings[DEFAULT_PALETTE_NAME].held.keys() + assert readings[name].highlights.keys() == readings[DEFAULT_PALETTE_NAME].highlights.keys() + + held, highlights = unchanged(list(readings.values())) + assert set(held) <= shared_colors(catalog) + assert set(highlights) <= shared_colors(catalog) + + def the_first_palette_again_paints_as_it_began(screen: Screen) -> None: + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + + assert painted(screen) == readings[DEFAULT_PALETTE_NAME] + + def cancel_leaves_nothing_of_the_preview(screen: Screen) -> None: + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + settings.choose_palette(other) + screen.frames(SETTLING_FRAMES) + assert painted(screen) == readings[other] + + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.frames(SETTLING_FRAMES) + after = painted(screen) + assert after.highlights == readings[DEFAULT_PALETTE_NAME].highlights + assert differing(after, readings[DEFAULT_PALETTE_NAME]) == {} + + screen.scenario( + read_the_palette_the_application_starts_in, + every_palette_repaints_what_differs, + the_first_palette_again_paints_as_it_began, + cancel_leaves_nothing_of_the_preview, + ).run() + + +class TestTwentySwaps: + """Twenty round trips between two palettes build nothing: the interface holds as many items after the twentieth + as after the second, and paints as it did. + """ + + def test_the_item_count_holds(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + counts: Dict[int, int] = {} + readings: List[Painted] = [] + + def swap_back_and_forth(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + readings.append(painted(screen)) + + for round_number in range(1, ROUNDS + 1): + settings.choose_palette(other) + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + counts[round_number] = screen.item_count() + + assert counts[ROUNDS] == counts[SECOND_ROUND] + assert painted(screen) == readings[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(swap_back_and_forth).run() diff --git a/tests/screens/interface/test_shortcuts.py b/tests/screens/interface/test_shortcuts.py new file mode 100644 index 000000000..280cb172a --- /dev/null +++ b/tests/screens/interface/test_shortcuts.py @@ -0,0 +1,252 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items import is_tag_within, read_windows +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import UNTITLED, expect_open, titled +from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION + +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +PLAY: Final[str] = "global.menu.label.item_playback_play" +SETTLING_FRAMES: Final[int] = 10 +DIALOG_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, str], ...]] = ( + (ShortcutId.DISPLAY_SETTINGS, TAG_SETTINGS_DISPLAY_WINDOW), + (ShortcutId.KEYBOARD_SETTINGS, TAG_SETTINGS_KEYBINDINGS_WINDOW), + (ShortcutId.AUDIO_SETTINGS, TAG_SETTINGS_AUDIO_WINDOW), + (ShortcutId.PROJECT_PROPERTIES, TAG_SETTINGS_PROPERTIES_WINDOW), + (ShortcutId.RENDER_SONG, TAG_SETTINGS_RENDER_WINDOW), +) +FILE_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, DialogKind], ...]] = ( + (ShortcutId.SAVE_PROJECT_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_FAMITRACKER, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_BITPHASE, DialogKind.SAVE), + (ShortcutId.RECONSTRUCT_FILE, DialogKind.OPEN), + (ShortcutId.RECONSTRUCT_DIRECTORY, DialogKind.DIRECTORY), + (ShortcutId.OPEN_RECONSTRUCTION, DialogKind.OPEN), + (ShortcutId.SAVE_RECONSTRUCTION_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_RECONSTRUCTION_WAV, DialogKind.SAVE), + (ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, DialogKind.SAVE), +) +TAB_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, Tab], ...]] = ( + (ShortcutId.SELECT_TAB_RECONSTRUCTIONS, Tab.RECONSTRUCTIONS), + (ShortcutId.SELECT_TAB_SEQUENCER, Tab.SEQUENCER), + (ShortcutId.SELECT_TAB_INSTRUCTIONS, Tab.INSTRUCTIONS), + (ShortcutId.SELECT_TAB_MAIN, Tab.MAIN), +) +CHANNEL_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, ChannelName], ...]] = ( + (ShortcutId.TOGGLE_CHANNEL_PULSE_1, ChannelName.PULSE1), + (ShortcutId.TOGGLE_CHANNEL_PULSE_2, ChannelName.PULSE2), + (ShortcutId.TOGGLE_CHANNEL_TRIANGLE, ChannelName.TRIANGLE), + (ShortcutId.TOGGLE_CHANNEL_NOISE, ChannelName.NOISE), +) +CHECKED_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements, MenuElements], ...]] = ( + (ShortcutId.TOGGLE_AUTOPLAY, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY), + (ShortcutId.TOGGLE_LOOP_SONG, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG), + (ShortcutId.TOGGLE_ADVANCED_SETTINGS, MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS), +) +FOLLOW_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements], ...]] = ( + (ShortcutId.FOLLOW_PATTERNS, MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS), + (ShortcutId.FOLLOW_OFF, MenuElements.ITEM_PLAYBACK_FOLLOW_OFF), + (ShortcutId.FOLLOW_ROWS, MenuElements.ITEM_PLAYBACK_FOLLOW_ROWS), +) +FOLLOW_ITEMS: Final[Tuple[MenuElements, ...]] = tuple(item for _, item in FOLLOW_SHORTCUTS) + + +def window_standing(window: str) -> bool: + """Whether a window standing under ``window`` is shown. Runs on the render thread.""" + return any(reading.shown and is_tag_within(reading.alias, window) for reading in read_windows()) + + +def checked(screen: Screen, group: MenuElements, label: str) -> bool: + return next(entry.checked for entry in screen.menu.entries(group) if entry.label == label) + + +def sounding(screen: Screen) -> List[bool]: + """Which channels Playback โ–ธ Channels marks as sounding, in channel order.""" + return [ + checked(screen, MenuElements.GROUP_PLAYBACK_CHANNELS, screen.channel_words(channel)) + for channel in ChannelName.items() + ] + + +def followed(screen: Screen) -> List[bool]: + return [checked(screen, MenuElements.GROUP_PLAYBACK_FOLLOW, screen.menu.label(item)) for item in FOLLOW_ITEMS] + + +def on_a_tab(screen: Screen, tab: Tab) -> None: + """Brings ``tab`` forward with a click, which leaves no field holding the keyboard.""" + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +class TestShortcutsOpeningDialogs: + """Each shortcut printed for a dialog opens that dialog, and each one printed for a file asks for that file.""" + + def test_each_opens_what_it_names(self, screen: Screen) -> None: + def each_dialog_shortcut(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, window in DIALOG_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + + screen.press_shortcut(shortcut_id) + + screen.expect(lambda: screen.bridge.ask(partial(window_standing, window)), bool, description=window) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.expect( + lambda: screen.bridge.ask(partial(window_standing, window)), + operator.not_, + description=f"{window} closed", + ) + + def each_file_shortcut(screen: Screen) -> None: + for shortcut_id, kind in FILE_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + asked = len(screen.dialog_requests()) + screen.answer_next_dialog(kind, None) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked + 1).__eq__, + description=f"the file {shortcut_id} asks for", + ) + assert screen.dialog_requests()[-1].kind == kind + + def open_project_asks_about_the_open_one_first(screen: Screen) -> None: + prompt = screen.project.replace_prompt + on_a_tab(screen, Tab.SEQUENCER) + asked = len(screen.dialog_requests()) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question about the open project") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert len(screen.dialog_requests()) == asked + screen.answer_next_dialog(DialogKind.OPEN, None) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question again") + prompt.confirm() + screen.expect( + lambda: len(screen.dialog_requests()), (asked + 1).__eq__, description="the project asked for" + ) + assert screen.dialog_requests()[-1].kind == DialogKind.OPEN + + screen.scenario(each_dialog_shortcut, each_file_shortcut, open_project_asks_about_the_open_one_first).run() + + +class TestShortcutsSwitchingWhatIsShown: + """Each tab, channel, mode and playback shortcut changes what its menu entry or the screen says it changes.""" + + def test_each_switches_its_state(self, screen: Screen) -> None: + playback = screen.sequencer.playback + + def tabs(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, tab in TAB_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(screen.tabs.front, tab.__eq__, description=f"{tab} in front") + + screen.press_shortcut(ShortcutId.NEXT_TAB) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the next tab in front") + screen.press_shortcut(ShortcutId.PREVIOUS_TAB) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the previous tab in front") + + def channels(screen: Screen) -> None: + on_a_tab(screen, Tab.SEQUENCER) + for index, (shortcut_id, channel) in enumerate(CHANNEL_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position > index for position in range(len(CHANNEL_SHORTCUTS))] + screen.expect(partial(sounding, screen), expected.__eq__, description=f"{channel} muted") + + for shortcut_id, channel in CHANNEL_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(partial(sounding, screen), all, description="every channel sounding again") + + def switches(screen: Screen) -> None: + for shortcut_id, group, item in CHECKED_SHORTCUTS: + on_a_tab(screen, Tab.SEQUENCER) + before = screen.menu.is_checked(group, item) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: screen.menu.is_checked(group, item), + (not before).__eq__, + description=f"{item} switched", + ) + screen.press_shortcut(shortcut_id) + screen.expect(lambda: screen.menu.is_checked(group, item), before.__eq__, description=f"{item} back") + + def follow_modes(screen: Screen) -> None: + for index, (shortcut_id, item) in enumerate(FOLLOW_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position == index for position in range(len(FOLLOW_ITEMS))] + screen.expect(partial(followed, screen), expected.__eq__, description=f"{item} alone checked") + + def play_and_stop(screen: Screen) -> None: + for start in (ShortcutId.PLAY, ShortcutId.PLAY_FROM_START): + screen.press_shortcut(start) + screen.expect(playback.play_entry, screen.words(PAUSE).__eq__, description=f"playing after {start}") + + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.play_entry, screen.words(PLAY).__eq__, description="stopped") + + screen.scenario(tabs, channels, switches, follow_modes, play_and_stop).run() + + +class TestShortcutsActingOnDocuments: + """Each shortcut closing or starting a document does what its entry says.""" + + def test_each_acts_as_named(self, screen: Screen) -> None: + voices = screen.sequencer.voices + reconstructions = screen.reconstructions + + def documents_close_and_a_new_project_opens(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + on_a_tab(screen, Tab.SEQUENCER) + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(reconstructions.file_line, operator.not_, description="the reconstruction closed") + + screen.press_shortcut(ShortcutId.CLOSE_PROJECT) + screen.expect(voices.names, operator.not_, description="the project closed") + + screen.press_shortcut(ShortcutId.NEW_PROJECT) + + screen.expect(screen.title, titled(screen, screen.words(UNTITLED)).__eq__, description="a new project") + + def exit_leaves(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + screen.scenario(documents_close_and_a_new_project_opens, exit_leaves).run() diff --git a/tests/screens/sequencer/test_colors.py b/tests/screens/sequencer/test_colors.py new file mode 100644 index 000000000..fb0775e13 --- /dev/null +++ b/tests/screens/sequencer/test_colors.py @@ -0,0 +1,205 @@ +import operator +from functools import partial +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_OFF +from tests.suite.screens.application import Startup +from tests.suite.screens.dearpygui.items import rounded_color +from tests.suite.screens.palettes import Color, shipped_palettes, token_color +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go +from tests.suite.screens.views.history import HistoryLine +from tests.suite.screens.world import ARRANGED_PROJECT, BASS_ROW, BASS_VOICE, LINE, PAD, PAD_ROW + +SAMPLE_TOKEN: Final[str] = "voice_sample" +INSTRUMENT_TOKEN: Final[str] = "voice_instrument" +NEUTRAL_TOKEN: Final[str] = "text_disabled" +CUT_KEY: Final[str] = "-" +FIRST_ROW: Final[int] = 0 +EMPTY_ROW: Final[int] = 1 +CUT_ROW: Final[int] = 2 +PLACED_ROW: Final[int] = 3 +TYPING_FRAMES: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 10 +RGB: Final[int] = 3 +ALPHA: Final[int] = 3 +LINE_NUMBER: Final[str] = "00" +FIRST_NUMBER: Final[str] = "01" +PAD_NUMBER: Final[str] = "02" +POSITION_MARK: Final[str] = ":" + + +@pytest.fixture +def startup() -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def type_into(screen: Screen, row: int, channel: ChannelName, text: str) -> None: + screen.sequencer.tracker.click(row, channel, SubColumn.VOICE) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + +def kind_mark(screen: Screen, voice: str) -> Color: + return rounded_color(screen.sequencer.voices.kind_color(voice)) + + +def voice_color(screen: Screen, row: int, channel: ChannelName) -> Optional[Color]: + return screen.sequencer.tracker.text_color(row, channel, SubColumn.VOICE) + + +def position_color(line: HistoryLine, position: str) -> Color: + """The color of the piece of ``line`` naming a voice by its ``position``.""" + return rounded_color( + next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + ) + + +def is_dimmed(color: Optional[Color], full: Color) -> bool: + """Whether ``color`` is ``full`` dimmed: the same hue, drawn fainter.""" + return color is not None and color[:RGB] == full[:RGB] and color[ALPHA] < full[ALPHA] + + +class TestKindColorsInEveryPalette: + """A recording reads in the sample color and a hand-written voice in the instrument color, as kind marks and as + tracker cells, in every palette and live on a swap; an empty cell and a cut read neutral, and a muted channel's + cells keep their hue, fainter. + """ + + def test_marks_and_cells_take_every_palette(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + settings = screen.display_settings + catalog = shipped_palettes() + + def cut_a_cell_and_mute_the_triangle(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + sample = token_color(catalog.get(DEFAULT_PALETTE_NAME), SAMPLE_TOKEN) + assert voice_color(screen, BASS_ROW, ChannelName.TRIANGLE) == sample + + type_into(screen, CUT_ROW, ChannelName.PULSE1, CUT_KEY) + tracker.click_header(ChannelName.TRIANGLE) + + screen.expect( + partial(tracker.label, CUT_ROW, ChannelName.PULSE1, SubColumn.VOICE), + NOTE_OFF.__eq__, + description="the cut", + ) + screen.expect( + partial(voice_color, screen, BASS_ROW, ChannelName.TRIANGLE), + partial(is_dimmed, full=sample), + description="the muted triangle's cell dimmed", + ) + + def painted_in(screen: Screen, palette: Palette) -> None: + sample = token_color(palette, SAMPLE_TOKEN) + instrument = token_color(palette, INSTRUMENT_TOKEN) + neutral = token_color(palette, NEUTRAL_TOKEN) + + assert kind_mark(screen, LINE) == sample + assert kind_mark(screen, BASS_VOICE) == sample + assert kind_mark(screen, PAD) == instrument + assert voice_color(screen, FIRST_ROW, ChannelName.PULSE1) == sample + assert voice_color(screen, PAD_ROW, ChannelName.PULSE2) == instrument + assert voice_color(screen, EMPTY_ROW, ChannelName.PULSE1) == neutral + assert voice_color(screen, CUT_ROW, ChannelName.PULSE1) == neutral + assert is_dimmed(voice_color(screen, BASS_ROW, ChannelName.TRIANGLE), sample) + + def every_palette_paints_the_kinds(screen: Screen) -> None: + painted_in(screen, catalog.get(DEFAULT_PALETTE_NAME)) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + for name in catalog.names: + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + + painted_in(screen, catalog.get(name)) + + settings.choose_palette(DEFAULT_PALETTE_NAME) + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario( + cut_a_cell_and_mute_the_triangle, + every_palette_paints_the_kinds, + leave_letting_the_project_go, + ).run() + + +class TestColorsFollowTheVoice: + """A voice's color travels with it: a placement's history line names it in its color, a move re-numbers and + re-colors its cells and leaves the others alone, and the lines naming it keep its color once it is removed. + """ + + def test_placed_moved_and_removed(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + voices = screen.sequencer.voices + history = screen.sequencer.history + palette = shipped_palettes().get(DEFAULT_PALETTE_NAME) + sample = token_color(palette, SAMPLE_TOKEN) + instrument = token_color(palette, INSTRUMENT_TOKEN) + placed: List[Tuple[HistoryLine, int]] = [] + + def place_the_instrument(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + before = len(history.lines()) + + type_into(screen, PLACED_ROW, ChannelName.PULSE1, PAD_NUMBER) + + lines = screen.expect(history.lines, lambda found: len(found) == before + 1, description="its entry") + assert position_color(lines[0], PAD_NUMBER) == instrument + assert voice_color(screen, PLACED_ROW, ChannelName.PULSE1) == instrument + placed.append((lines[0], before + 1)) + + def moving_it_up_renumbers_its_cells_alone(screen: Screen) -> None: + row = screen.expect_item(partial(voices.row, PAD), description="the instrument's row") + voices.pick(row) + + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP) + + screen.expect(voices.names, [LINE, PAD, BASS_VOICE].__eq__, description="the instrument moved up") + screen.expect( + partial(tracker.label, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE), + FIRST_NUMBER.__eq__, + description="its cell renumbered", + ) + for row_index, channel, number, color in ( + (PAD_ROW, ChannelName.PULSE2, FIRST_NUMBER, instrument), + (PLACED_ROW, ChannelName.PULSE1, FIRST_NUMBER, instrument), + (BASS_ROW, ChannelName.TRIANGLE, PAD_NUMBER, sample), + (FIRST_ROW, ChannelName.PULSE1, LINE_NUMBER, sample), + ): + assert tracker.label(row_index, channel, SubColumn.VOICE) == number + assert voice_color(screen, row_index, channel) == color + + def removed_its_older_lines_keep_its_color(screen: Screen) -> None: + prompt = voices.remove_prompt + row = screen.expect_item(partial(voices.row, PAD), description="the instrument's row") + voices.pick(row) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + + prompt.confirm() + + screen.expect(voices.names, [LINE, BASS_VOICE].__eq__, description="the instrument removed") + line, count = placed[0] + lines = history.lines() + older = lines[len(lines) - count] + assert older.segments == line.segments + assert position_color(older, PAD_NUMBER) == instrument + forgive_the_hover_race(screen) + + screen.scenario( + place_the_instrument, + moving_it_up_renumbers_its_cells_alone, + removed_its_older_lines_keep_its_color, + leave_letting_the_project_go, + ).run() diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application.py index 4ab1a7d74..943a25444 100644 --- a/tests/suite/screens/application.py +++ b/tests/suite/screens/application.py @@ -12,6 +12,7 @@ from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import TableHighlights from tests.suite.screens.boundaries.reveals import FileManagerStandIn from tests.suite.screens.boundaries.spawns import SpawnGuard from tests.suite.screens.checks import ( @@ -60,6 +61,7 @@ class Boundaries: spawns: SpawnGuard holds: Holds file_manager: FileManagerStandIn + highlights: TableHighlights class ScreenApplication: diff --git a/tests/suite/screens/boundaries/highlights.py b/tests/suite/screens/boundaries/highlights.py new file mode 100644 index 000000000..0d6c88331 --- /dev/null +++ b/tests/suite/screens/boundaries/highlights.py @@ -0,0 +1,111 @@ +import threading +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, Final, Optional, Sequence, Tuple, Union + +import dearpygui.dearpygui as dpg +import pytest + +TableItem = Union[int, str] +Color = Tuple[float, ...] + +WHOLE_LINE: Final[None] = None + + +class HighlightKind(StrEnum): + ROW = "row" + CELL = "cell" + COLUMN = "column" + + +@dataclass(frozen=True) +class HighlightPlace: + """Where a table highlight stands: the table, the stretch it covers, and the row and the column it covers. + + Attributes: + table: The table's tag. + kind: Whether the highlight covers a row, a cell or a column. + row: The row it covers, or ``None`` for a column. + column: The column it covers, or ``None`` for a row. + """ + + table: str + kind: HighlightKind + row: Optional[int] + column: Optional[int] + + +class TableHighlights: + """Notes every highlight the application lays on a table and lifts from one, with its color. + + DearPyGui holds a table's row, cell and column highlights as the table's own state, and answers + whether one stands but never its color. The stand-in wraps the calls that lay and lift them, + which run as they did, so the highlights standing and their colors are known as the + application left them, from the first frame on. + """ + + def __init__(self) -> None: + self._standing: Dict[HighlightPlace, Color] = {} + self._lock = threading.Lock() + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + lay_row = dpg.highlight_table_row + lay_cell = dpg.highlight_table_cell + lay_column = dpg.highlight_table_column + lift_row = dpg.unhighlight_table_row + lift_cell = dpg.unhighlight_table_cell + lift_column = dpg.unhighlight_table_column + + def highlight_row(table: TableItem, row: int, color: Sequence[float]) -> None: + lay_row(table, row, color) + self._lay(_place(table, HighlightKind.ROW, row, WHOLE_LINE), color) + + def highlight_cell(table: TableItem, row: int, column: int, color: Sequence[float]) -> None: + lay_cell(table, row, column, color) + self._lay(_place(table, HighlightKind.CELL, row, column), color) + + def highlight_column(table: TableItem, column: int, color: Sequence[float]) -> None: + lay_column(table, column, color) + self._lay(_place(table, HighlightKind.COLUMN, WHOLE_LINE, column), color) + + def unhighlight_row(table: TableItem, row: int) -> None: + lift_row(table, row) + self._lift(_place(table, HighlightKind.ROW, row, WHOLE_LINE)) + + def unhighlight_cell(table: TableItem, row: int, column: int) -> None: + lift_cell(table, row, column) + self._lift(_place(table, HighlightKind.CELL, row, column)) + + def unhighlight_column(table: TableItem, column: int) -> None: + lift_column(table, column) + self._lift(_place(table, HighlightKind.COLUMN, WHOLE_LINE, column)) + + monkeypatch.setattr(dpg, "highlight_table_row", highlight_row) + monkeypatch.setattr(dpg, "highlight_table_cell", highlight_cell) + monkeypatch.setattr(dpg, "highlight_table_column", highlight_column) + monkeypatch.setattr(dpg, "unhighlight_table_row", unhighlight_row) + monkeypatch.setattr(dpg, "unhighlight_table_cell", unhighlight_cell) + monkeypatch.setattr(dpg, "unhighlight_table_column", unhighlight_column) + + def standing(self) -> Dict[HighlightPlace, Color]: + """Every highlight standing, with the color it was laid in.""" + with self._lock: + return dict(self._standing) + + def _lay(self, place: HighlightPlace, color: Sequence[float]) -> None: + with self._lock: + self._standing[place] = tuple(float(part) for part in color) + + def _lift(self, place: HighlightPlace) -> None: + with self._lock: + self._standing.pop(place, None) + + +def _place( + table: TableItem, + kind: HighlightKind, + row: Optional[int], + column: Optional[int], +) -> HighlightPlace: + """The place a call names, its table known by its tag. Runs on the render thread.""" + return HighlightPlace(table=str(dpg.get_item_alias(table) or table), kind=kind, row=row, column=column) diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py index b8b6b26b0..098d560a1 100644 --- a/tests/suite/screens/dearpygui/items.py +++ b/tests/suite/screens/dearpygui/items.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Callable, Dict, Final, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple, Union import dearpygui.dearpygui as dpg @@ -14,6 +14,14 @@ MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" TEXT_TYPE: Final[str] = "mvAppItemType::mvText" BUTTON_TYPE: Final[str] = "mvAppItemType::mvButton" +THEME_COLOR_TYPE: Final[str] = "mvAppItemType::mvThemeColor" +COLOR_ATTRIBUTES: Final[Tuple[str, ...]] = ("color", "fill") +THEME_COLOR_VALUE: Final[str] = "value" +THEME_TARGET: Final[str] = "target" +THEME_CATEGORY: Final[str] = "category" +COLOR_PARTS: Final[int] = 4 +COLOR_DIGITS: Final[int] = 4 +COLOR_STEPS: Final[float] = 255.0 TAG_SEPARATOR: Final[str] = "." @@ -301,6 +309,68 @@ def read_theme_colors(theme: Item) -> Tuple[Tuple[float, ...], ...]: return tuple(colors) +def read_held_colors() -> Dict[str, Tuple[float, ...]]: + """Every color DearPyGui holds for the interface, keyed by the item and the attribute holding it. Runs on the + render thread. + + A theme color holds its value whether or not anything wearing it is drawn; an item holds its own color and fill + while it stands shown. Every value reads as fractions of the full step, rounded, so a color given in whole steps + reads the same from either side. + """ + held: Dict[str, Tuple[float, ...]] = {} + for item in dpg.get_all_items(): + if dpg.get_item_info(item)["type"] == THEME_COLOR_TYPE: + held[f"{item}:{THEME_COLOR_VALUE}"] = _theme_color(item) + continue + + if not dpg.is_item_shown(item): + continue + + configuration = dpg.get_item_configuration(item) + for attribute in COLOR_ATTRIBUTES: + value = configuration.get(attribute) + if _is_color(value): + held[f"{item}:{attribute}"] = rounded_color(value) + + return held + + +def read_theme_text_color(theme: Item) -> Optional[Tuple[float, ...]]: + """The text color ``theme`` sets, if it sets one. Runs on the render thread.""" + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + configuration = dpg.get_item_configuration(color) + if ( + configuration.get(THEME_TARGET) == dpg.mvThemeCol_Text + and configuration.get(THEME_CATEGORY) == dpg.mvThemeCat_Core + ): + return _theme_color(color) + + return None + + +def rounded_color(value: Sequence[float]) -> Tuple[float, ...]: + """A color's parts, rounded so a color read from DearPyGui and one stated in whole steps compare equal.""" + return tuple(round(float(part), COLOR_DIGITS) for part in value) + + +def _theme_color(item: Item) -> Tuple[float, ...]: + """The color a theme color item holds, as a fraction of the full step like an item's own color reads. + + DearPyGui answers a theme color in whole steps and an item's color in fractions of one. + """ + return rounded_color(tuple(float(part) / COLOR_STEPS for part in dpg.get_value(item))) + + +def _is_color(value: object) -> bool: + """Whether a configuration value is a color DearPyGui holds, which an unset one, read as negatives, is not.""" + return ( + isinstance(value, (list, tuple)) + and len(value) == COLOR_PARTS + and all(isinstance(part, (int, float)) and part >= 0 for part in value) + ) + + def read_visible_text(words: str) -> bool: """Whether a text item reading ``words`` was drawn in the last frame, such as a tooltip standing open. @@ -394,6 +464,11 @@ def read_viewport() -> Rect: ) +def read_viewport_decorated() -> bool: + """Whether the window wears the system's title bar and frame. Runs on the render thread.""" + return bool(dpg.is_viewport_decorated()) + + def read_viewport_title() -> str: """The title the viewport's window carries in its title bar. Runs on the render thread.""" return str(dpg.get_viewport_title()) diff --git a/tests/suite/screens/dearpygui/screenshot.py b/tests/suite/screens/dearpygui/screenshot.py index a431d3fbc..13a14f9a0 100644 --- a/tests/suite/screens/dearpygui/screenshot.py +++ b/tests/suite/screens/dearpygui/screenshot.py @@ -1,5 +1,8 @@ +import threading +from collections.abc import Buffer +from dataclasses import dataclass from pathlib import Path -from typing import Final +from typing import Final, List import dearpygui.dearpygui as dpg @@ -22,3 +25,37 @@ def capture(bridge: Bridge, path: Path) -> Path: timeout=WRITE_TIMEOUT_SECONDS, ) return path + + +@dataclass(frozen=True) +class DrawnFrame: + """One frame as DearPyGui drew it: its size, and its pixels row after row, each red, green, blue and alpha as + 32-bit fractions. + """ + + width: int + height: int + pixels: bytes + + +def drawn_frame(bridge: Bridge) -> DrawnFrame: + """The next frame the application draws. + + DearPyGui hands the frame over while drawing it, so the reading waits for it to land. + """ + landed: List[DrawnFrame] = [] + taken = threading.Event() + + def take(_sender: object, frame: Buffer) -> None: + landed.append( + DrawnFrame( + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + pixels=memoryview(frame).tobytes(), + ) + ) + taken.set() + + bridge.ask(lambda: dpg.output_frame_buffer(callback=take)) + bridge.expect(taken.is_set, bool, description="the frame to be handed over", timeout=WRITE_TIMEOUT_SECONDS) + return landed[0] diff --git a/tests/suite/screens/palettes.py b/tests/suite/screens/palettes.py new file mode 100644 index 000000000..29154c97c --- /dev/null +++ b/tests/suite/screens/palettes.py @@ -0,0 +1,36 @@ +from typing import Final, FrozenSet, Sequence, Tuple + +from sampletones_application.paths import PALETTES_DIRECTORY +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.palette import Palette +from tests.suite.screens.dearpygui.items import rounded_color + +Color = Tuple[float, ...] + +FULL_STEP: Final[float] = 255.0 + + +def shipped_palettes() -> PaletteCatalog: + """The palettes this build ships, the ones Display settings offers.""" + return PaletteCatalog.load(PALETTES_DIRECTORY) + + +def token_color(palette: Palette, token: str) -> Color: + """The color ``token`` names in ``palette``, as DearPyGui holds it.""" + return in_fractions(palette.colors[token]) + + +def in_fractions(color: Sequence[float]) -> Color: + """A color given in whole steps, as DearPyGui holds it: each part a fraction of the full step.""" + return rounded_color(tuple(part / FULL_STEP for part in color)) + + +def shared_colors(catalog: PaletteCatalog) -> FrozenSet[Color]: + """Every color all the shipped palettes state alike under one token, which no palette swap changes.""" + palettes = tuple(catalog.palettes.values()) + first = palettes[0] + return frozenset( + token_color(first, token) + for token in first.colors + if all(palette.colors.get(token) == first.colors[token] for palette in palettes) + ) diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py index 373ee0b7a..368891093 100644 --- a/tests/suite/screens/plugin.py +++ b/tests/suite/screens/plugin.py @@ -10,6 +10,7 @@ from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import TableHighlights from tests.suite.screens.boundaries.reveals import FileManagerStandIn from tests.suite.screens.boundaries.spawns import SpawnGuard from tests.suite.screens.dearpygui.bridge import Bridge @@ -239,6 +240,8 @@ def screen_boundaries( spawns = SpawnGuard() spawns.install() spawns.allow(file_manager.folder) + highlights = TableHighlights() + highlights.install(monkeypatch) provide_output_device( output_device, home=Path.home(), @@ -250,6 +253,7 @@ def screen_boundaries( spawns=spawns, holds=screen_holds, file_manager=file_manager, + highlights=highlights, ) @@ -299,6 +303,7 @@ def screen( dialogs=screen_boundaries.dialogs, errors=screen_boundaries.errors, file_manager=screen_boundaries.file_manager, + highlights=screen_boundaries.highlights, artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), ) window_manager.close() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 758b2cc08..71b0d5b6f 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -1,6 +1,8 @@ from contextlib import contextmanager from pathlib import Path -from typing import Callable, Final, Iterator, Optional, Tuple, TypeVar, Union +from typing import Callable, Dict, Final, Iterator, Optional, Tuple, TypeVar, Union + +import numpy as np from sampletones_application.categories.context import channel_label, generator_label from sampletones_application.categories.key.text import TextKeyTuple @@ -20,6 +22,7 @@ from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import HighlightPlace, TableHighlights from tests.suite.screens.boundaries.reveals import FileManagerStandIn from tests.suite.screens.dearpygui.bridge import Bridge, ExpectationError from tests.suite.screens.dearpygui.geometry import Point @@ -36,7 +39,7 @@ read_windows, ) from tests.suite.screens.dearpygui.recording import FrameRecording -from tests.suite.screens.dearpygui.screenshot import capture +from tests.suite.screens.dearpygui.screenshot import capture, drawn_frame from tests.suite.screens.dearpygui.windows import WindowManager from tests.suite.screens.keyboard import press_combination, primary_combination from tests.suite.screens.render_thread import QueueRenderThread @@ -61,6 +64,7 @@ STEP_SEPARATOR: Final[str] = " โ†’ " SCREENSHOT_SUFFIX: Final[str] = ".png" STATUS_BAR: Final[str] = compose_tag(TAG_GLOBAL_STATUS_BAR, SUF_BUTTON) +PIXEL_CHANNELS: Final[int] = 4 class Screen: @@ -84,6 +88,7 @@ def __init__( dialogs: ScriptedFileDialogs, errors: ErrorRecords, file_manager: FileManagerStandIn, + highlights: TableHighlights, artifacts: Path, ) -> None: self.bridge = bridge @@ -94,6 +99,7 @@ def __init__( self._dialogs = dialogs self._errors = errors self._file_manager = file_manager + self._highlights = highlights self._artifacts = artifacts self._language = language self.tabs = Tabs(bridge, hand) @@ -131,6 +137,15 @@ def words(self, key: Union[str, TextKeyTuple]) -> str: """What the language file says under ``key``, which is what the application shows the user.""" return self._language[key] + def shortcut_words(self, shortcut_id: ShortcutId) -> str: + """How a menu prints the keys of ``shortcut_id`` under the scheme in place.""" + return self._shortcuts.shortcut(shortcut_id).display() + + def frame_pixels(self) -> np.ndarray: + """The next frame drawn, as rows of pixels of red, green, blue and alpha fractions.""" + frame = drawn_frame(self.bridge) + return np.frombuffer(frame.pixels, dtype=np.float32).reshape(frame.height, frame.width, PIXEL_CHANNELS) + def channel_words(self, channel: ChannelName) -> str: """The name every display gives ``channel``.""" return channel_label(self._language, channel) @@ -221,6 +236,10 @@ def revealed(self) -> Tuple[Path, ...]: """Every path the application asked the desktop's file manager to show, in order.""" return self._file_manager.shown() + def table_highlights(self) -> Dict[HighlightPlace, Tuple[float, ...]]: + """Every highlight standing on a table, with the color the application laid it in.""" + return self._highlights.standing() + def shown_windows(self) -> Tuple[WindowReading, ...]: """Every window standing on the screen besides the application's main one.""" return tuple( diff --git a/tests/suite/screens/views/display_settings.py b/tests/suite/screens/views/display_settings.py index ae6003564..54c7dcb75 100644 --- a/tests/suite/screens/views/display_settings.py +++ b/tests/suite/screens/views/display_settings.py @@ -1,3 +1,7 @@ +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_BUTTON @@ -8,16 +12,22 @@ TAG_SETTINGS_DISPLAY_BUTTON_REVERT, TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, + TAG_SETTINGS_DISPLAY_COMBO_PALETTE, + TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, TAG_SETTINGS_DISPLAY_DIALOG_DISCARD, TAG_SETTINGS_DISPLAY_WINDOW, TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN, ) +from sampletones_application.utils.callbacks.queue import CallbackQueue from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt +ITEMS: Final[str] = "items" + class DisplaySettings: """The Display settings dialog: the window mode, the pacing and the palette, with Cancel and OK.""" @@ -40,6 +50,33 @@ def open(self) -> None: def is_shown(self) -> bool: return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW)).shown + def resolution(self) -> str: + """The window size the Resolution list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION))) + + def resolutions(self) -> Tuple[str, ...]: + """The window sizes the Resolution list offers, in order.""" + return tuple( + str(item) + for item in self._bridge.ask(lambda: dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION))[ + ITEMS + ] + ) + + def choose_resolution(self, label: str) -> None: + """Picks the window size reading ``label`` in the Resolution list, which resizes the window and starts the + countdown. + """ + self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, label, CallbackQueue.run)) + + def palette(self) -> str: + """The palette the Palette list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_COMBO_PALETTE))) + + def choose_palette(self, name: str) -> None: + """Picks the palette ``name`` in the Palette list, which repaints the interface at once.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, name, CallbackQueue.run)) + def vsync(self) -> bool: """Whether the vertical sync box stands ticked.""" return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC))) diff --git a/tests/suite/screens/views/keyboard_settings.py b/tests/suite/screens/views/keyboard_settings.py index a44efbdd2..0ed9f2865 100644 --- a/tests/suite/screens/views/keyboard_settings.py +++ b/tests/suite/screens/views/keyboard_settings.py @@ -5,6 +5,7 @@ PRE_SETTINGS_KEYBINDINGS_ROW, SUF_SETTINGS_KEYBINDINGS_SHORTCUT, TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, + TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, TAG_SETTINGS_KEYBINDINGS_WINDOW, ) @@ -50,6 +51,10 @@ def keys_of(self, shortcut_id: ShortcutId) -> str: def cancel(self) -> None: self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, SUF_BUTTON)) + def confirm(self) -> None: + """Presses OK, which puts the keys edited in force.""" + self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, SUF_BUTTON)) + def _shortcut_cell(shortcut_id: ShortcutId) -> str: return compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, shortcut_id.value, SUF_SETTINGS_KEYBINDINGS_SHORTCUT) diff --git a/tests/suite/screens/views/main.py b/tests/suite/screens/views/main.py index f876d6a78..6731d945d 100644 --- a/tests/suite/screens/views/main.py +++ b/tests/suite/screens/views/main.py @@ -6,6 +6,7 @@ from sampletones_application.tags.general import ( SUF_BUTTON, SUF_COLLAPSE_BODY, + SUF_COLLAPSE_RAIL, SUF_COLLAPSE_STRIP, SUF_TOOLTIP, TAG_GLOBAL_THEME_STEP_BUTTON_LIT, @@ -58,6 +59,24 @@ def toggle(self) -> None: self._hand.scroll_into_view(strip) self._hand.click(strip) + def folds_sideways(self) -> bool: + """Whether the card folds into a rail at its side, rather than into its bar.""" + return self._bridge.ask(lambda: read_item(self.rail)).exists + + @property + def rail(self) -> str: + """The rail a sideways card folds into, which a click unfolds.""" + return compose_tag(self.tag, SUF_COLLAPSE_RAIL) + + @property + def strip(self) -> str: + """The header bar a click folds the card on.""" + return compose_tag(self.tag, SUF_COLLAPSE_STRIP) + + def unfold_from_the_rail(self) -> None: + """Clicks the rail a sideways card stands folded into, which unfolds it.""" + self._hand.click(self.rail) + class Main: """The Main tab: its cards, the converter's settings and the folders the advanced settings name.""" diff --git a/tests/suite/screens/views/menus.py b/tests/suite/screens/views/menus.py index 5992e3b31..89e2beb95 100644 --- a/tests/suite/screens/views/menus.py +++ b/tests/suite/screens/views/menus.py @@ -18,6 +18,7 @@ find_labelled, read_item, read_label, + read_value, read_viewport, ) from tests.suite.screens.dearpygui.semantic import invoke @@ -31,11 +32,12 @@ @dataclass(frozen=True) class MenuEntry: - """One entry of a menu on the bar: its words, whether it answers, and the keys printed beside it.""" + """One entry of a menu on the bar: its words, whether it answers, the keys printed beside it, and its check mark.""" label: str enabled: bool keys: str + checked: bool class MenuBar: @@ -66,8 +68,13 @@ def choose( def open(self, group: MenuElements) -> None: """Clicks the header of the menu ``group``, which opens its popup.""" + corner = self.header(group) + self._hand.click_at(Point(x=corner.x + HEADER_INSET.x, y=corner.y + HEADER_INSET.y)) + + def header(self, group: MenuElements) -> Point: + """Where the header of the menu ``group`` stands on the bar, its top left corner.""" corner = self._bridge.ask(lambda: dpg.get_item_state(self._menu(group))["pos"]) - self._hand.click_at(Point(x=round(corner[0]) + HEADER_INSET.x, y=round(corner[1]) + HEADER_INSET.y)) + return Point(x=round(corner[0]), y=round(corner[1])) def close(self) -> None: """Clicks the bare far end of the tab bar, clear of every menu's popup, which puts an open menu away.""" @@ -87,6 +94,7 @@ def read() -> List[MenuEntry]: label=read_label(entry), enabled=bool(dpg.get_item_configuration(entry).get(ENABLED, True)), keys=str(dpg.get_item_configuration(entry).get(SHORTCUT, "")), + checked=bool(read_value(entry)), ) for entry in _menu_items(self._menu(group)) ] @@ -101,9 +109,17 @@ def is_enabled( """Whether the entry ``item`` of the menu ``group`` answers a press.""" return self._bridge.ask(lambda: read_item(self._entry(group, item)).enabled) + def is_checked( + self, + group: MenuElements, + item: MenuElements, + ) -> bool: + """Whether the entry ``item`` of the menu ``group`` carries its check mark.""" + return bool(self._bridge.ask(lambda: read_value(self._entry(group, item)))) + def _menu(self, group: MenuElements) -> Item: """The menu ``group`` on the bar. Runs on the render thread.""" - return find_labelled(TAG_GLOBAL_WINDOW_MAIN, self._label(group), item_type=MENU_TYPE) + return find_labelled(TAG_GLOBAL_WINDOW_MAIN, self.label(group), item_type=MENU_TYPE) def _entry( self, @@ -113,16 +129,17 @@ def _entry( """The entry ``item`` of the menu ``group``. Runs on the render thread.""" menu = find_labelled( TAG_GLOBAL_WINDOW_MAIN, - self._label(group), + self.label(group), item_type=MENU_TYPE, ) return find_labelled( menu, - self._label(item), + self.label(item), item_type=MENU_ITEM_TYPE, ) - def _label(self, element: MenuElements) -> str: + def label(self, element: MenuElements) -> str: + """The words the menu bar shows for ``element``.""" return self._language[ Page.GLOBAL, Panel.MENU, diff --git a/tests/suite/screens/views/tracker.py b/tests/suite/screens/views/tracker.py index 1b3ab459e..1759ef41a 100644 --- a/tests/suite/screens/views/tracker.py +++ b/tests/suite/screens/views/tracker.py @@ -10,7 +10,7 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, read_item, read_label +from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_theme_text_color from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT from tests.suite.screens.dearpygui.reach import UnreachableError @@ -37,6 +37,14 @@ def tracker_cell_theme(row: int, channel: Optional[ChannelName], subcolumn: SubC return int(theme) if theme is not None else None +def _header_label(channel: Optional[ChannelName]) -> Item: + """The name heading ``channel``'s column, the Sample column's for ``None``. Runs on the render thread.""" + table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[HEADER_ROW] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + label: Item = dpg.get_item_children(cell, 1)[CELL_GROUP] + return label + + class Tracker: """The tracker grid: a row per pattern row, the Sample column and a column per channel, three slots to a cell. @@ -54,11 +62,19 @@ def __init__( def header(self, channel: Optional[ChannelName]) -> str: """What the header of ``channel``'s column reads, the Sample column's for ``None``.""" + return self._bridge.ask(lambda: read_label(_header_label(channel))) + + def click_header(self, channel: ChannelName) -> None: + """Clicks the name heading ``channel``'s column, which mutes the channel or lets it sound again.""" + label = self._bridge.ask(lambda: _header_label(channel)) + self._hand.click(label) + + def text_color(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[Tuple[float, ...]]: + """The color one slot's text is drawn in, the theme it wears states.""" - def read() -> str: - table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[HEADER_ROW] - cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] - return read_label(dpg.get_item_children(cell, 1)[CELL_GROUP]) + def read() -> Optional[Tuple[float, ...]]: + theme = tracker_cell_theme(row, channel, subcolumn) + return None if theme is None else read_theme_text_color(theme) return self._bridge.ask(read) From e7439969af7b31d5835aa1e6cd89fb4cfd0b8793 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 2 Oct 2026 20:28:33 +0200 Subject: [PATCH 14/19] Proved: closing mid-work and the exit chain on a live screen --- docs/development/application/screens.md | 16 +- docs/development/bugs-and-todos.md | 4 + tests/screens/application/test_closing.py | 300 ++++++++++++++++++++- tests/screens/prompts/test_open_voice.py | 4 +- tests/suite/screens/dearpygui/isolation.py | 24 ++ 5 files changed, 337 insertions(+), 11 deletions(-) diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 3cf80a898..093353b97 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -90,7 +90,9 @@ keeps running, which is why the checks read the log as well as the screen. of its own outlives the exit. A known bug is a case marked `xfail(strict=True)` that names its [ledger](../bugs-and-todos.md) entry. The -mark makes the run fail once the bug is fixed, until the mark goes. +mark makes the run fail once the bug is fixed, until the mark goes. A known crash is marked the same way: +the parent reports a marked scenario whose process a signal killed as the failure it expects, naming the +signal. ### 7. The DearPyGui layer knows nothing of SampleToNES @@ -105,7 +107,7 @@ something, and it moves to a project of its own the day a second application wan The parent pytest run starts each scenario as a child pytest run of that one test, in a fresh interpreter. The child writes its reports as it goes, and the parent reports them as its own, so selecting, rerunning and strict expected failures behave as for any test. A child that crashes or runs out of time is reported as a -failure carrying its output. +failure carrying its output, unless a crash is the known bug its scenario is marked with. Inside the child, the application is built on the main thread, where its render loop runs. The scenario runs on a thread of its own. Every reading and every gesture crosses to the render thread through the @@ -150,8 +152,8 @@ A scenario speaks through views, one per tab, card and dialog, under `tests/suit knows its controls by their tags and its words by their language keys. A tag that moves changes one view, and a scenario reads as the gestures a user makes. -What DearPyGui reports no item for is read from the frame it draws: `screen.frame_pixels()` hands over the -next frame, which is how a menu popup's width is measured. +What only the drawn frame shows is read from it: `screen.frame_pixels()` hands over the next frame, which is +how a menu popup's width is measured. ## The boundaries @@ -169,9 +171,9 @@ next frame, which is how a menu popup's width is measured. runs, and `screen.revealed()` reads what it showed. - **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request a title bar's close button sends. -- **Table highlights.** DearPyGui keeps a table's row, cell and column highlights without a way to read - their color back, so the calls laying and lifting them are wrapped from the first frame on, and - `screen.table_highlights()` reads what stands. +- **Table highlights.** DearPyGui holds a table's row, cell and column highlights as table state and + reports whether one stands, so the calls laying and lifting them are wrapped from the first frame on, and + `screen.table_highlights()` reads each highlight standing with its color. ## Holding work in flight diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 63ec3f990..bdbe186c4 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -226,3 +226,7 @@ currently out of line. An entry leaves when the code meets the contract again. writes that size: leaving records the live window size, while the dialog keeps the session at the values it opened with until OK, and the window manager's close passes the open dialog by. `tests/screens/interface/test_display.py` reproduces it as a known failure. +* Closing the window while a folder is being read crashes the process once the read ends: nothing stops the + walk on exit, so it runs past the shutdown, and its report closes the reading window through DearPyGui + after the context is gone (SIGSEGV). `tests/screens/application/test_closing.py` reproduces it as a + known failure. diff --git a/tests/screens/application/test_closing.py b/tests/screens/application/test_closing.py index f36105601..2e18c8363 100644 --- a/tests/screens/application/test_closing.py +++ b/tests/screens/application/test_closing.py @@ -1,13 +1,49 @@ import operator -from typing import Final +from typing import Final, List -from sampletones_shared.paths.user import PROJECTS_DIRECTORY +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.extensions import EXT_FILE_MODULE +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.application import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.holds import ConversionHold, ExportHold, RegenerationHold, ScanHold from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds import Recording +from tests.suite.screens.steps.main import gather, home_path from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title +from tests.suite.screens.steps.reconstructions import expect_open, raise_the_first_level_while_held +from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.world import ( + ARRANGED_PROJECT, + BASS, + OPEN_RECONSTRUCTION, + PLAYABLE_RECONSTRUCTION, + SONG, + World, + documents_world, + lived_in_world, + playing_world, + sequencer_world, +) +from tests.suite.screens.written import written_state PROJECT_FILENAME: Final[str] = "Closing.stp" SAVED_TITLE: Final[str] = "Before closing" NEW_TITLE: Final[str] = "Closing title" +CONVERSION_RUNNING: Final[str] = "global.dialog.message.exit_conversion_in_progress" +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +RUN_TIMEOUT_SECONDS: Final[float] = 60.0 +SETTLING_FRAMES: Final[int] = 30 +FOLDER: Final[str] = "Many" +FOLDER_RECORDINGS: Final[int] = 130 +RECORDING_SECONDS: Final[float] = 0.05 +RECORDING_FREQUENCY: Final[float] = 330.0 +SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" def save_a_titled_project(screen: Screen) -> None: @@ -86,3 +122,263 @@ def discard_and_leave(screen: Screen) -> None: close_again, discard_and_leave, ).run() + + +def conversion_question(screen: Screen) -> Prompt: + """The question exiting asks while a conversion runs, told apart from the reconstruction's by its words.""" + return screen.reconstructions.unsaved_prompt + + +def asks_about_the_conversion(screen: Screen) -> bool: + prompt = conversion_question(screen) + return prompt.is_shown() and screen.words(CONVERSION_RUNNING) in prompt.words() + + +def close_and_leave(screen: Screen, tab: Tab) -> None: + """Closes the window with ``tab`` in front, waits for the application to stop, and checks the session written.""" + screen.close_window() + + assert screen.wait_for_exit() + assert written_state().current.tab is tab + + +class TestClosingWhilePlaying: + """Closing the window while a reconstruction plays leaves at once, quietly, and writes the session.""" + + @pytest.fixture + def world(self) -> World: + return playing_world() + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_it_leaves_while_the_sound_goes_on(self, screen: Screen) -> None: + def play(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.INSTRUCTIONS) + + screen.scenario(play, close).run() + + +class TestClosingDuringAConversion: + """Closing the window while a conversion runs asks first: Cancel keeps both going, Exit stops the run and leaves, + writing nothing for it. + """ + + @pytest.fixture + def world(self) -> World: + return documents_world() + + def test_it_asks_and_then_stops_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + converter = screen.main.converter + prompt = conversion_question(screen) + + def start_a_held_run(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def cancel_keeps_it_running(screen: Screen) -> None: + screen.close_window() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert converter.progress() > 0 + + def exit_stops_it_and_leaves(screen: Screen) -> None: + screen.close_window() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question again") + + prompt.confirm() + + assert screen.wait_for_exit() + assert written_state().current.tab is Tab.MAIN + assert not list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{home_path(BASS).stem}.*")) + + screen.scenario(start_a_held_run, cancel_keeps_it_running, exit_stops_it_and_leaves).run() + + +class TestClosingDuringAFolderRead: + """Closing the window while a folder is read leaves, the read given up, quietly, and writes the session.""" + + @pytest.fixture + def world(self) -> World: + lived_in = lived_in_world() + return World( + state=lived_in.state, + application_config=None, + config=None, + files=tuple( + Recording( + destination=home_path(FOLDER) / f"take{index:03d}.wav", + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + for index in range(FOLDER_RECORDINGS) + ), + ) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a folder read outliving the exit calls DearPyGui after its context is gone", + ) + def test_it_leaves_the_read_behind(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def start_reading(screen: Screen) -> None: + row = screen.expect_item( + lambda: screen.explorer.file_row(home_path(FOLDER)), + description="the folder's row", + ) + screen.explorer.ctrl_click(row) + screen.expect(converter.scan_shown, bool, description="the folder being read") + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.MAIN) + + screen.scenario(start_reading, close).run() + + +class TestClosingDuringAnExport: + """Closing the window while an export runs leaves, the export stopped with no file written.""" + + @pytest.fixture + def world(self) -> World: + return sequencer_world() + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_it_leaves_and_writes_nothing(self, screen: Screen, export_hold: ExportHold) -> None: + destination = PROJECTS_DIRECTORY / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_a_held_export(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.SAVE, destination) + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + screen.expect(screen.exports.progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.SEQUENCER) + + assert not destination.exists() + + screen.scenario(start_a_held_export, close).run() + + +class TestClosingDuringARebuild: + """Closing the window while an edit is on its way waits for it to land, then asks about the edited document.""" + + @pytest.fixture + def world(self) -> World: + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_it_waits_and_then_asks(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + prompt = screen.reconstructions.unsaved_prompt + + def edit_while_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_waits_for_the_edit(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert not prompt.is_shown() + + def the_edit_lands_and_the_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about the edited reconstruction") + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(edit_while_held, close_waits_for_the_edit, the_edit_lands_and_the_question_comes).run() + + +class TestExitWithEverythingPending: + """Exit with an unsaved project, an unsaved reconstruction and a conversion running asks about each, in that + order, and leaves once the last is answered. + """ + + @pytest.fixture + def world(self) -> World: + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_questions_come_in_order( + self, + screen: Screen, + conversion_hold: ConversionHold, + regeneration_hold: RegenerationHold, + ) -> None: + converter = screen.main.converter + asked: List[str] = [] + + def change_both_documents_and_start_a_run(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + retitle_project(screen, NEW_TITLE) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + regeneration_hold.release() + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def answer_each_question(screen: Screen) -> None: + project = screen.project.unsaved_prompt + reconstruction = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project.is_shown, bool, description="the question about the project") + asked.append("project") + project.confirm() + screen.expect( + lambda: reconstruction.is_shown() and not asks_about_the_conversion(screen), + bool, + description="the question about the reconstruction", + ) + asked.append("reconstruction") + reconstruction.confirm() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + asked.append("conversion") + conversion_question(screen).confirm() + + assert screen.wait_for_exit() + assert asked == ["project", "reconstruction", "conversion"] + + screen.scenario(change_both_documents_and_start_a_run, answer_each_question).run() diff --git a/tests/screens/prompts/test_open_voice.py b/tests/screens/prompts/test_open_voice.py index c9c279955..a1e35dedb 100644 --- a/tests/screens/prompts/test_open_voice.py +++ b/tests/screens/prompts/test_open_voice.py @@ -50,7 +50,7 @@ def open_the_sample(screen: Screen) -> None: open_voice(screen, SONG_SAMPLE) screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") - assert screen.tabs.front() is Tab.RECONSTRUCTIONS + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") class TestAVoiceDoubleClickedOverAnEditedReconstruction: @@ -95,7 +95,7 @@ def discard_opens_the_voice(screen: Screen) -> None: prompt.confirm() screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") - assert screen.tabs.front() is Tab.RECONSTRUCTIONS + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] screen.scenario( diff --git a/tests/suite/screens/dearpygui/isolation.py b/tests/suite/screens/dearpygui/isolation.py index 776c2f8a2..0ca53d7f9 100644 --- a/tests/suite/screens/dearpygui/isolation.py +++ b/tests/suite/screens/dearpygui/isolation.py @@ -21,6 +21,8 @@ ) OUTPUT_TAIL_CHARACTERS: Final[int] = 20_000 NODE_SEPARATOR: Final[str] = "::" +XFAIL_MARKER: Final[str] = "xfail" +XFAIL_REASON: Final[str] = "reason" @dataclass(frozen=True) @@ -35,6 +37,11 @@ def crashed(self) -> bool: """Whether the process ended by a signal or ran out of time.""" return self.returncode is None or self.returncode < 0 + @property + def killed_by_signal(self) -> bool: + """Whether the process ended by a signal, such as a crash.""" + return self.returncode is not None and self.returncode < 0 + @property def ending(self) -> str: """The way the process ended, in words a failure report can carry.""" @@ -177,6 +184,23 @@ def _missing_report( *, failed: bool, ) -> pytest.TestReport: + """The report of a phase the child never wrote, since the process ended first. + + A scenario marked to fail by a known defect, whose process a signal killed, reports that phase as the expected + failure it names: a crash is a defect the ledger records like any other. + """ + expected = item.get_closest_marker(XFAIL_MARKER) + if failed and child.killed_by_signal and expected is not None: + return pytest.TestReport( + nodeid=item.nodeid, + location=item.location, + keywords={keyword: 1 for keyword in item.keywords}, + outcome="skipped", + longrepr=None, + when=phase, + wasxfail=f"{expected.kwargs.get(XFAIL_REASON, '')} (the process {child.ending})", + ) + longrepr = None if failed: longrepr = ( From bd0385f72f4cdf5f26117f633d6c4eea8c7ae663 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 14:01:55 +0200 Subject: [PATCH 15/19] Added: common screens tests vocabulary --- docs/development/application/screens.md | 37 ++++++++ .../integration/tooling/test_screen_layout.py | 91 +++++++++++++++++++ tests/screens/application/test_closing.py | 4 +- tests/screens/instructions/test_waveform.py | 2 +- tests/screens/interface/test_shortcuts.py | 3 +- tests/screens/main/test_gathering.py | 8 +- tests/screens/main/test_list.py | 8 +- tests/screens/main/test_mix.py | 2 +- tests/screens/main/test_row_settings.py | 7 +- tests/screens/main/test_run.py | 5 +- tests/screens/main/test_scan.py | 3 +- .../prompts/test_closing_over_a_modal.py | 2 +- tests/screens/prompts/test_every_door.py | 26 +++--- tests/screens/prompts/test_keys.py | 4 +- tests/screens/prompts/test_load_chain.py | 6 +- .../screens/prompts/test_modal_over_modal.py | 5 +- tests/screens/prompts/test_open_voice.py | 6 +- tests/screens/prompts/test_vanished.py | 6 +- .../reconstructions/test_instruments.py | 2 +- tests/screens/reconstructions/test_player.py | 3 +- tests/screens/reconstructions/test_stems.py | 2 +- tests/screens/sequencer/test_tracker.py | 2 +- tests/screens/sequencer/test_voices.py | 2 +- tests/suite/screens/paths.py | 1 + tests/suite/screens/vocabulary/__init__.py | 0 tests/suite/screens/vocabulary/converter.py | 9 ++ tests/suite/screens/vocabulary/dialogs.py | 14 +++ tests/suite/screens/vocabulary/playback.py | 4 + tests/suite/screens/vocabulary/recordings.py | 4 + 29 files changed, 199 insertions(+), 69 deletions(-) create mode 100644 tests/integration/tooling/test_screen_layout.py create mode 100644 tests/suite/screens/vocabulary/__init__.py create mode 100644 tests/suite/screens/vocabulary/converter.py create mode 100644 tests/suite/screens/vocabulary/dialogs.py create mode 100644 tests/suite/screens/vocabulary/playback.py create mode 100644 tests/suite/screens/vocabulary/recordings.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 093353b97..e0bb5a821 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -224,6 +224,43 @@ short enough to read before every change. A bug fix's scenario is shown failing on the code before the fix, and the pull request says so. +## How the scenarios are laid out + +`tests/screens/` has a package per area of the application (`application`, `exports`, `instructions`, +`interface`, `main`, `prompts`, `reconstructions`, `sequencer`), and an area has a package per subject. +A subject package holds one file per responsibility: the classes in a file prove one family of rules of +that subject, and a file that grows past about 300 lines or starts answering a second question is split +by subject. A file name says its responsibility, such as `test_note_keys.py`. + +What several files of a subject share sits in modules of the package, written once two files need it: + +| Module | Holds | +|---|---| +| `constants.py` | Scalar `Final` constants and language keys | +| `cases.py` | Case dataclasses and the tables over them | +| `steps.py` | Steps, readings and the builders of a world | +| `conftest.py` | Fixtures every file of the package uses, such as `world` and `startup` | + +What several areas share lives in `tests/suite/screens/vocabulary/`, one module per area: the keys of +the dialogs and their messages, of the converter and of playback, and the names of the seeded recordings. +A value that one file alone uses stays in that file. Constants stand directly under the imports in every +module, so a reader meets the values before the code that uses them, and a case in +`tests/integration/tooling/` holds that order. Scenario files import from `constants.py`, `cases.py`, +`steps.py` and the vocabulary, and never from one another. + +### Docstrings + +A docstring tells a reader what is tested, how the scenario goes and what is expected, in a few plain +sentences. + +- A test class opens with the rule it proves, told from the user's side. A second sentence gives the + logic where the method names leave it open: the setup, the gestures and the witness a negative ends with. +- A test method gets one sentence on what is expected where its name does not already say. +- A fixture says what the home holds or what opens at start. +- A step or a reading says what it does and what it leaves behind. +- A case type says what one row means, and each of its fields what it holds. +- The steps defined inside a test carry their gesture as their name and need no docstring. + ## Running and watching `make screens` runs every scenario across a few workers, each on an Xvfb of its own. `make system-deps` diff --git a/tests/integration/tooling/test_screen_layout.py b/tests/integration/tooling/test_screen_layout.py new file mode 100644 index 000000000..f5bb3ca10 --- /dev/null +++ b/tests/integration/tooling/test_screen_layout.py @@ -0,0 +1,91 @@ +import ast +from pathlib import Path +from typing import Final, FrozenSet, Iterator, List + +from sampletones_shared.paths.source import REPOSITORY_ROOT +from tests.suite.screens.paths import SCREEN_DRIVER_DIRECTORY, SCREENS_DIRECTORY + +SCENARIO_FILE_PREFIX: Final[str] = "test_" +SCREEN_TIER_DIRECTORIES: Final[List[Path]] = [SCREENS_DIRECTORY, SCREEN_DRIVER_DIRECTORY] +NOT_YET_REORGANIZED: Final[FrozenSet[str]] = frozenset( + { + "tests/screens/application/test_old_files.py", + "tests/screens/application/test_restart.py", + "tests/screens/exports/test_import.py", + "tests/screens/exports/test_progress.py", + "tests/screens/interface/test_dialog_sweep.py", + "tests/screens/main/test_list.py", + "tests/screens/main/test_row_settings.py", + "tests/screens/prompts/test_every_door.py", + "tests/screens/sequencer/test_history.py", + "tests/suite/screens/application.py", + "tests/suite/screens/boundaries/dialogs.py", + "tests/suite/screens/dearpygui/display.py", + } +) + + +def screen_tier_modules() -> Iterator[Path]: + for directory in SCREEN_TIER_DIRECTORIES: + yield from sorted(directory.rglob("*.py")) + + +def relative(path: Path) -> str: + return path.relative_to(REPOSITORY_ROOT).as_posix() + + +def constants_after_code(path: Path) -> List[int]: + """The lines of module-level assignments that follow the module's first function or class.""" + lines: List[int] = [] + code_started = False + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + code_started = True + elif code_started and isinstance(node, (ast.Assign, ast.AnnAssign)): + lines.append(node.lineno) + + return lines + + +def imported_scenario_modules(path: Path) -> Iterator[str]: + """Every scenario file (``test_*.py``) that ``path`` imports by its absolute name.""" + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None: + if node.module.rsplit(".", 1)[-1].startswith(SCENARIO_FILE_PREFIX): + yield node.module + + +class TestTheScreenTierLayout: + """The screen tier's modules read the same way: constants under the imports, packages that import, scenarios that stand alone.""" + + def test_constants_stand_under_the_imports(self) -> None: + offenders = { + relative(path): constants_after_code(path) + for path in screen_tier_modules() + if relative(path) not in NOT_YET_REORGANIZED and constants_after_code(path) + } + + assert offenders == {} + + def test_every_folder_of_modules_is_a_package(self) -> None: + folders = {path.parent for path in screen_tier_modules()} + + assert [relative(folder) for folder in sorted(folders) if not (folder / "__init__.py").exists()] == [] + + def test_no_module_imports_a_scenario_file(self) -> None: + importers = { + relative(path): list(imported_scenario_modules(path)) + for path in screen_tier_modules() + if list(imported_scenario_modules(path)) + } + + assert importers == {} + + def test_the_list_of_modules_not_yet_reorganized_names_files_that_still_need_it(self) -> None: + finished = [ + entry + for entry in sorted(NOT_YET_REORGANIZED) + if not (REPOSITORY_ROOT / entry).exists() or not constants_after_code(REPOSITORY_ROOT / entry) + ] + + assert finished == [] diff --git a/tests/screens/application/test_closing.py b/tests/screens/application/test_closing.py index 2e18c8363..c86188d5e 100644 --- a/tests/screens/application/test_closing.py +++ b/tests/screens/application/test_closing.py @@ -18,6 +18,8 @@ from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title from tests.suite.screens.steps.reconstructions import expect_open, raise_the_first_level_while_held from tests.suite.screens.views.prompts import Prompt +from tests.suite.screens.vocabulary.converter import SCAN_PROGRESS +from tests.suite.screens.vocabulary.playback import PAUSE from tests.suite.screens.world import ( ARRANGED_PROJECT, BASS, @@ -36,14 +38,12 @@ SAVED_TITLE: Final[str] = "Before closing" NEW_TITLE: Final[str] = "Closing title" CONVERSION_RUNNING: Final[str] = "global.dialog.message.exit_conversion_in_progress" -PAUSE: Final[str] = "global.menu.label.item_playback_pause" RUN_TIMEOUT_SECONDS: Final[float] = 60.0 SETTLING_FRAMES: Final[int] = 30 FOLDER: Final[str] = "Many" FOLDER_RECORDINGS: Final[int] = 130 RECORDING_SECONDS: Final[float] = 0.05 RECORDING_FREQUENCY: Final[float] = 330.0 -SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" def save_a_titled_project(screen: Screen) -> None: diff --git a/tests/screens/instructions/test_waveform.py b/tests/screens/instructions/test_waveform.py index 932cf0242..b2fbbc8cc 100644 --- a/tests/screens/instructions/test_waveform.py +++ b/tests/screens/instructions/test_waveform.py @@ -6,11 +6,11 @@ from tests.suite.screens.dearpygui.items import read_label from tests.suite.screens.screen import Screen from tests.suite.screens.steps.instructions import load_library +from tests.suite.screens.vocabulary.playback import PAUSE from tests.suite.screens.world import one_worker_config LISTENING_FRAMES: Final[int] = 30 ZOOM_NOTCHES: Final[int] = 3 -PAUSE: Final[str] = "global.menu.label.item_playback_pause" def show_the_pulse(screen: Screen) -> None: diff --git a/tests/screens/interface/test_shortcuts.py b/tests/screens/interface/test_shortcuts.py index 280cb172a..b764cfe94 100644 --- a/tests/screens/interface/test_shortcuts.py +++ b/tests/screens/interface/test_shortcuts.py @@ -21,10 +21,9 @@ from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import UNTITLED, expect_open, titled +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION -PAUSE: Final[str] = "global.menu.label.item_playback_pause" -PLAY: Final[str] = "global.menu.label.item_playback_play" SETTLING_FRAMES: Final[int] = 10 DIALOG_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, str], ...]] = ( (ShortcutId.DISPLAY_SETTINGS, TAG_SETTINGS_DISPLAY_WINDOW), diff --git a/tests/screens/main/test_gathering.py b/tests/screens/main/test_gathering.py index e2ebf64a3..369ed92df 100644 --- a/tests/screens/main/test_gathering.py +++ b/tests/screens/main/test_gathering.py @@ -17,10 +17,10 @@ from tests.suite.screens.dearpygui.items import Item from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording, WrittenBytes +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, CONVERT_NOTHING, CONVERT_ONE, FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK, SNARE from tests.suite.screens.world import HomeFile, World, lived_in_world -KICK: Final[str] = "kick.wav" -SNARE: Final[str] = "snare.wav" TAKES: Final[str] = "Takes" INNER: Final[str] = "Inner" LOOPS: Final[str] = "Loops" @@ -37,10 +37,6 @@ FIRST_COUNTED_ROUND: Final[int] = 2 POINTER_REACH: Final[int] = 4 -FOLDER_ROW: Final[str] = "global.stems.template.folder_row" -CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" -CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" -CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" ADD_AS_STEM: Final[str] = "main.explorer.label.context_add_stem" ADD_FOLDER: Final[str] = "main.explorer.label.context_add_folder_stems" NOTHING_BELOW: Final[str] = "main.converter.message.scan_nothing_below" diff --git a/tests/screens/main/test_list.py b/tests/screens/main/test_list.py index c16bd4820..24ff9c098 100644 --- a/tests/screens/main/test_list.py +++ b/tests/screens/main/test_list.py @@ -22,10 +22,10 @@ from tests.suite.screens.dearpygui.items import Item, read_item, read_scroll from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording +from tests.suite.screens.vocabulary.converter import CONVERT_ONE, CONVERT_SEVERAL, FOLDER_ROW, REMOVE_RECORDING +from tests.suite.screens.vocabulary.recordings import KICK, SNARE from tests.suite.screens.world import HomeFile, World, lived_in_world -KICK: Final[str] = "kick.wav" -SNARE: Final[str] = "snare.wav" FORTY: Final[str] = "Forty" FORTY_COUNT: Final[int] = 40 PLAYED_SECONDS: Final[float] = 2.0 @@ -35,13 +35,9 @@ SAME_LINE_PIXELS: Final[float] = 1.0 HEADING: Final[str] = "heading" -FOLDER_ROW: Final[str] = "global.stems.template.folder_row" SHOW_RECORDINGS: Final[str] = "main.converter.label.context_open_folder" HIDE_RECORDINGS: Final[str] = "main.converter.label.context_close_folder" -REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" PLAY: Final[str] = "global.context.label.play" -CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" -CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" CHANNEL_NAMES: Final[Dict[ChannelName, str]] = { ChannelName.PULSE1: "global.context.label.pulse_1", ChannelName.PULSE2: "global.context.label.pulse_2", diff --git a/tests/screens/main/test_mix.py b/tests/screens/main/test_mix.py index e29f755e4..946f84604 100644 --- a/tests/screens/main/test_mix.py +++ b/tests/screens/main/test_mix.py @@ -10,6 +10,7 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import REMOVE_RECORDING from tests.suite.screens.world import World, lived_in_world GATHERED: Final[int] = MAX_STEM_SOURCES + 2 @@ -18,7 +19,6 @@ NAMES_CLICKED: Final[int] = 4 COUNT_LINE: Final[str] = "main.converter.template.stem_selection_limit" -REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" MOVES: Final[List[str]] = [ "main.converter.label.context_move_up", "main.converter.label.context_move_down", diff --git a/tests/screens/main/test_row_settings.py b/tests/screens/main/test_row_settings.py index bf0ecd34f..1e1c5dc72 100644 --- a/tests/screens/main/test_row_settings.py +++ b/tests/screens/main/test_row_settings.py @@ -17,10 +17,10 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording from tests.suite.screens.steps.main import choose_from_the_row_menu, gather, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, FOLDER_ROW, REMOVE_RECORDING +from tests.suite.screens.vocabulary.recordings import KICK, SNARE from tests.suite.screens.world import HomeFile, World, converting_world, lived_in_world -KICK: Final[str] = "kick.wav" -SNARE: Final[str] = "snare.wav" PAIR: Final[str] = "Pair" PAIR_TAKES: Final[Tuple[str, ...]] = ("left.wav", "right.wav") PLAYED_SECONDS: Final[float] = 2.0 @@ -29,9 +29,6 @@ HELD_TIMEOUT_SECONDS: Final[float] = 60.0 NEW_RECORDINGS: Final[str] = "main.source.label.new_recordings" -FOLDER_ROW: Final[str] = "global.stems.template.folder_row" -REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" -CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" PROGRESS: Final[str] = "main.converter.template.progress_template" diff --git a/tests/screens/main/test_run.py b/tests/screens/main/test_run.py index 0b58df21f..5c0d3aa3b 100644 --- a/tests/screens/main/test_run.py +++ b/tests/screens/main/test_run.py @@ -16,6 +16,7 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording from tests.suite.screens.steps.main import explorer_row, gather, give_each_its_own_channel, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, CONVERT_NOTHING, CONVERT_ONE, CONVERT_SEVERAL from tests.suite.screens.world import HomeFile, World, converting_world SECONDS: Final[float] = 0.3 @@ -31,11 +32,7 @@ RECONSTRUCTION_SUFFIX: Final[str] = ".stn" OTHER_RECONSTRUCTION: Final[str] = "other.stn" -CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" -CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" -CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" MIX_SEVERAL: Final[str] = "main.converter.template.mix_recordings" -CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" RECONSTRUCT_FILE: Final[str] = "main.explorer.label.context_reconstruct_file" RUN_FAILED: Final[str] = "main.converter.message.status_error" diff --git a/tests/screens/main/test_scan.py b/tests/screens/main/test_scan.py index a1edd9cc7..4c4766f64 100644 --- a/tests/screens/main/test_scan.py +++ b/tests/screens/main/test_scan.py @@ -9,6 +9,7 @@ from tests.suite.screens.holds import Holds, ReleaseSignal, ScanHold from tests.suite.screens.screen import Screen from tests.suite.screens.seeds import Recording +from tests.suite.screens.vocabulary.converter import FOLDER_ROW, SCAN_PROGRESS from tests.suite.screens.world import HomeFile, World, lived_in_world MANY: Final[str] = "Many" @@ -21,8 +22,6 @@ SLOW_ENTRY_SECONDS: Final[float] = 2.0 SLOW_RELEASE_FILE: Final[str] = "release-slow-scan" -SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" -FOLDER_ROW: Final[str] = "global.stems.template.folder_row" RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" diff --git a/tests/screens/prompts/test_closing_over_a_modal.py b/tests/screens/prompts/test_closing_over_a_modal.py index 6180410ae..4169d0b83 100644 --- a/tests/screens/prompts/test_closing_over_a_modal.py +++ b/tests/screens/prompts/test_closing_over_a_modal.py @@ -18,13 +18,13 @@ raise_the_first_level_while_held, titled, ) +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION SETTLING_FRAMES: Final[int] = 30 PROJECT: Final[str] = "Closing.stp" SAVED_TITLE: Final[str] = "Before closing" NEW_TITLE: Final[str] = "After closing" -EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" def change_a_saved_project(screen: Screen) -> None: diff --git a/tests/screens/prompts/test_every_door.py b/tests/screens/prompts/test_every_door.py index 53163404c..eb8c95ad3 100644 --- a/tests/screens/prompts/test_every_door.py +++ b/tests/screens/prompts/test_every_door.py @@ -24,6 +24,20 @@ voice_title, ) from tests.suite.screens.steps.sequencer import double_click_voice, open_voice +from tests.suite.screens.vocabulary.dialogs import ( + CANCEL, + CLOSE, + CLOSE_MESSAGE, + CLOSE_TITLE, + DISCARD, + EDIT_MESSAGE, + EDIT_TITLE, + EXIT_PROJECT_MESSAGE, + EXIT_RECONSTRUCTION_MESSAGE, + LOAD_MESSAGE, + LOAD_TITLE, + SAVE, +) from tests.suite.screens.world import ( BASS, LEAD, @@ -37,20 +51,8 @@ SETTLING_FRAMES: Final[int] = 10 SAMPLE_ORDINAL: Final[int] = 0 FADING: Final[str] = "12 8 4" -LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" -LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" -CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" -CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" -EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" -EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" EXIT_TITLE: Final[str] = "global.dialog.title.exit_confirmation" -EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" -EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" -SAVE: Final[str] = "global.dialog.label.save" -DISCARD: Final[str] = "global.dialog.label.discard" -CLOSE: Final[str] = "global.dialog.label.close" EXIT: Final[str] = "global.dialog.label.exit" -CANCEL: Final[str] = "global.dialog.label.cancel" class Door(StrEnum): diff --git a/tests/screens/prompts/test_keys.py b/tests/screens/prompts/test_keys.py index 827d62275..c13e065b3 100644 --- a/tests/screens/prompts/test_keys.py +++ b/tests/screens/prompts/test_keys.py @@ -22,14 +22,12 @@ stored_levels, titled, ) +from tests.suite.screens.vocabulary.dialogs import CLOSE_MESSAGE, EXIT_PROJECT_MESSAGE, EXIT_RECONSTRUCTION_MESSAGE from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE RETITLED: Final[str] = "Retitled from the keyboard" KEPT: Final[str] = "Kept.stn" SETTLING_FRAMES: Final[int] = 10 -EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" -EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" -CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" def tab(screen: Screen, times: int) -> None: diff --git a/tests/screens/prompts/test_load_chain.py b/tests/screens/prompts/test_load_chain.py index 275e955c0..930dffc84 100644 --- a/tests/screens/prompts/test_load_chain.py +++ b/tests/screens/prompts/test_load_chain.py @@ -20,15 +20,11 @@ stored_levels, titled, ) +from tests.suite.screens.vocabulary.dialogs import CANCEL, DISCARD, LOAD_MESSAGE, LOAD_TITLE, SAVE from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) -LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" -LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" REPLACED_MESSAGE: Final[str] = "global.dialog.message.load_replaced_reconstruction" -SAVE: Final[str] = "global.dialog.label.save" -DISCARD: Final[str] = "global.dialog.label.discard" -CANCEL: Final[str] = "global.dialog.label.cancel" def convert_and_ask_to_load(screen: Screen, recording: str) -> None: diff --git a/tests/screens/prompts/test_modal_over_modal.py b/tests/screens/prompts/test_modal_over_modal.py index b61ca6b14..3fc7cd82b 100644 --- a/tests/screens/prompts/test_modal_over_modal.py +++ b/tests/screens/prompts/test_modal_over_modal.py @@ -21,15 +21,14 @@ remove_from_the_browser, titled, ) +from tests.suite.screens.vocabulary.converter import CONVERT_ONE +from tests.suite.screens.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE from tests.suite.screens.world import BASS, OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION SETTLING_FRAMES: Final[int] = 30 KEPT: Final[str] = "Kept.stn" LOCKED_FOLDER: Final[str] = "Locked" READ_AND_ENTER: Final[int] = stat.S_IRUSR | stat.S_IXUSR -CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" -LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" -CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" SAVE_FAILED: Final[str] = "global.dialog.message.reconstruction_save_failed" REBUILD_FAILURE: Final[str] = "The rebuild broke on its worker" LISTENING: Final[str] = "settings.keybindings.message.capturing" diff --git a/tests/screens/prompts/test_open_voice.py b/tests/screens/prompts/test_open_voice.py index a1e35dedb..9df7cda98 100644 --- a/tests/screens/prompts/test_open_voice.py +++ b/tests/screens/prompts/test_open_voice.py @@ -17,16 +17,12 @@ voice_title, ) from tests.suite.screens.steps.sequencer import double_click_voice, open_voice, voice_row +from tests.suite.screens.vocabulary.dialogs import CANCEL, DISCARD, EDIT_MESSAGE, EDIT_TITLE, SAVE from tests.suite.screens.world import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE SAMPLE_ORDINAL: Final[int] = 0 PERSONS_HOLD_FRAMES: Final[int] = 10 PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) -EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" -EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" -SAVE: Final[str] = "global.dialog.label.save" -DISCARD: Final[str] = "global.dialog.label.discard" -CANCEL: Final[str] = "global.dialog.label.cancel" REMOVE_VOICE: Final[str] = "sequencer.voices.label.context_remove" FADING: Final[str] = "12 8 4" diff --git a/tests/screens/prompts/test_vanished.py b/tests/screens/prompts/test_vanished.py index 1cf314799..4d86bc266 100644 --- a/tests/screens/prompts/test_vanished.py +++ b/tests/screens/prompts/test_vanished.py @@ -15,16 +15,12 @@ stored_levels, titled, ) +from tests.suite.screens.vocabulary.dialogs import CANCEL, CLOSE, CLOSE_MESSAGE, CLOSE_TITLE, SAVE from tests.suite.screens.world import OPEN_RECONSTRUCTION KEPT: Final[str] = "Kept.stn" PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) SETTLING_FRAMES: Final[int] = 10 -CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" -CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" -SAVE: Final[str] = "global.dialog.label.save" -CLOSE: Final[str] = "global.dialog.label.close" -CANCEL: Final[str] = "global.dialog.label.cancel" NOT_APPLICABLE: Final[str] = "reconstructions.reconstruction.label.path_not_applicable" RATE_SET_BY_THE_PROJECT: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_locked" diff --git a/tests/screens/reconstructions/test_instruments.py b/tests/screens/reconstructions/test_instruments.py index 7b047f43a..fefe9c2f0 100644 --- a/tests/screens/reconstructions/test_instruments.py +++ b/tests/screens/reconstructions/test_instruments.py @@ -21,6 +21,7 @@ from tests.suite.screens.steps.reconstructions import edit_envelope, expect_open, marked, titled from tests.suite.screens.steps.sequencer import open_voice from tests.suite.screens.views.waveform import read_cursor +from tests.suite.screens.vocabulary.playback import PLAY from tests.suite.screens.world import OPEN_RECONSTRUCTION, PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" @@ -63,7 +64,6 @@ TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" KEPT_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" INVALID_INPUT: Final[str] = "Invalid PULSE1 data input for VOLUME" -PLAY: Final[str] = "global.menu.label.item_playback_play" def status_over(screen: Screen, item: str) -> str: diff --git a/tests/screens/reconstructions/test_player.py b/tests/screens/reconstructions/test_player.py index 34ac90ffa..0a5a7b72e 100644 --- a/tests/screens/reconstructions/test_player.py +++ b/tests/screens/reconstructions/test_player.py @@ -12,6 +12,7 @@ from tests.suite.screens.seeds import Recording from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser from tests.suite.screens.views.waveform import Waveform +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY from tests.suite.screens.world import ( PLAYABLE_RECONSTRUCTION, SECOND_PLAYABLE, @@ -31,8 +32,6 @@ BEAT_LENGTH: Final[int] = 3 SILENCE: Final[float] = 1e-6 SILENT_SHARE_TOLERANCE: Final[float] = 0.05 -PLAY: Final[str] = "global.menu.label.item_playback_play" -PAUSE: Final[str] = "global.menu.label.item_playback_pause" RESUME: Final[str] = "global.menu.label.item_playback_resume" ORIGINAL_SWITCH: Final[str] = "reconstructions.reconstruction.label.original_audio_radio" RECONSTRUCTION_SWITCH: Final[str] = "reconstructions.reconstruction.label.reconstruction_radio" diff --git a/tests/screens/reconstructions/test_stems.py b/tests/screens/reconstructions/test_stems.py index 80c4ba082..806ebffe6 100644 --- a/tests/screens/reconstructions/test_stems.py +++ b/tests/screens/reconstructions/test_stems.py @@ -12,6 +12,7 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser, marked, titled from tests.suite.screens.views.stems import StemsCard +from tests.suite.screens.vocabulary.playback import PAUSE from tests.suite.screens.world import ( SHORT_RECONSTRUCTION, STEM_TAKES, @@ -28,7 +29,6 @@ REMOVE_TITLE: Final[str] = "reconstructions.reconstruction.title.remove_stem_dialog" REMOVE_MESSAGE: Final[str] = "reconstructions.reconstruction.message.remove_stem_message" NOTHING_EXPORTED: Final[str] = "global.context.template.size_bytes" -PAUSE: Final[str] = "global.menu.label.item_playback_pause" def take(index: int) -> Path: diff --git a/tests/screens/sequencer/test_tracker.py b/tests/screens/sequencer/test_tracker.py index a3487166c..0725dfa71 100644 --- a/tests/screens/sequencer/test_tracker.py +++ b/tests/screens/sequencer/test_tracker.py @@ -15,6 +15,7 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.steps.sequencer import leave_letting_the_project_go from tests.suite.screens.views.tracker import tracker_cell, tracker_cell_theme +from tests.suite.screens.vocabulary.playback import PAUSE from tests.suite.screens.world import ARRANGED_PROJECT, BASS_ROW, PAD_ROW PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") @@ -31,7 +32,6 @@ NO_VOICE_ROW: Final[int] = 2 SAMPLE_COLUMN: Final[Optional[ChannelName]] = None SAMPLE_HEADER: Final[str] = "sequencer.tracker.label.column_sample" -PAUSE: Final[str] = "global.menu.label.item_playback_pause" SET_VOICE: Final[str] = "sequencer.tracker.label.context_set_voice" CLEAR_SUBCOLUMN: Final[str] = "sequencer.tracker.label.context_clear_subcolumn" SUBCOLUMN_LETTERS: Final[Tuple[Tuple[SubColumn, str], ...]] = ( diff --git a/tests/screens/sequencer/test_voices.py b/tests/screens/sequencer/test_voices.py index 9a62842f2..c065551aa 100644 --- a/tests/screens/sequencer/test_voices.py +++ b/tests/screens/sequencer/test_voices.py @@ -20,6 +20,7 @@ from tests.suite.screens.steps.reconstructions import BY_CONFIGURATION from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go from tests.suite.screens.views.menus import MenuEntry +from tests.suite.screens.vocabulary.playback import PAUSE from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD, SHORT_RECONSTRUCTION POOL_ACTIONS: Final[Tuple[str, ...]] = ( @@ -35,7 +36,6 @@ "global.context.label.triangle", "global.context.label.noise", ) -PAUSE: Final[str] = "global.menu.label.item_playback_pause" EMPTY_SPACE_INSET: Final[int] = 10 SETTLING_FRAMES: Final[int] = 20 MOVE_DOWN_KEYS: Final[str] = "Alt+Down" diff --git a/tests/suite/screens/paths.py b/tests/suite/screens/paths.py index fab9ab77e..558ba4a7c 100644 --- a/tests/suite/screens/paths.py +++ b/tests/suite/screens/paths.py @@ -4,6 +4,7 @@ from sampletones_shared.paths.source import REPOSITORY_ROOT SCREENS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "screens" +SCREEN_DRIVER_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" DEARPYGUI_LAYER_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" / "dearpygui" ARTIFACTS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "screens" HOME_FOLDER: Final[str] = "home" diff --git a/tests/suite/screens/vocabulary/__init__.py b/tests/suite/screens/vocabulary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/vocabulary/converter.py b/tests/suite/screens/vocabulary/converter.py new file mode 100644 index 000000000..7bf3ff04e --- /dev/null +++ b/tests/suite/screens/vocabulary/converter.py @@ -0,0 +1,9 @@ +from typing import Final + +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" +CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" +CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" +REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" +SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" diff --git a/tests/suite/screens/vocabulary/dialogs.py b/tests/suite/screens/vocabulary/dialogs.py new file mode 100644 index 000000000..f87b96900 --- /dev/null +++ b/tests/suite/screens/vocabulary/dialogs.py @@ -0,0 +1,14 @@ +from typing import Final + +SAVE: Final[str] = "global.dialog.label.save" +DISCARD: Final[str] = "global.dialog.label.discard" +CANCEL: Final[str] = "global.dialog.label.cancel" +CLOSE: Final[str] = "global.dialog.label.close" +LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" +LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" +CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" +CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" +EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" +EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" +EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" +EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" diff --git a/tests/suite/screens/vocabulary/playback.py b/tests/suite/screens/vocabulary/playback.py new file mode 100644 index 000000000..89e6f9c04 --- /dev/null +++ b/tests/suite/screens/vocabulary/playback.py @@ -0,0 +1,4 @@ +from typing import Final + +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +PLAY: Final[str] = "global.menu.label.item_playback_play" diff --git a/tests/suite/screens/vocabulary/recordings.py b/tests/suite/screens/vocabulary/recordings.py new file mode 100644 index 000000000..db28f5648 --- /dev/null +++ b/tests/suite/screens/vocabulary/recordings.py @@ -0,0 +1,4 @@ +from typing import Final + +KICK: Final[str] = "kick.wav" +SNARE: Final[str] = "snare.wav" From ce25c714802f0d73017053e43025512567f71b03 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 14:56:06 +0200 Subject: [PATCH 16/19] Reorganized: screen tier into subject packages with shared vocabulary --- docs/development/application/screens.md | 26 +- docs/development/bugs-and-todos.md | 45 +- .../integration/tooling/test_screen_layers.py | 4 +- .../integration/tooling/test_screen_layout.py | 30 +- .../closing}/__init__.py | 0 .../screens/application/closing/constants.py | 5 + tests/screens/application/closing/steps.py | 14 + .../test_during_work.py} | 236 ++---- .../application/closing/test_exit_chain.py | 88 +++ .../closing/test_idle_and_unsaved.py | 101 +++ tests/screens/application/leaving/__init__.py | 0 .../leaving/test_exit_shortcut.py} | 30 +- .../screens/application/old_files/__init__.py | 0 tests/screens/application/old_files/cases.py | 23 + .../application/old_files/constants.py | 11 + tests/screens/application/old_files/steps.py | 48 ++ .../old_files/test_broken_projects.py | 181 +++++ .../old_files/test_broken_reconstructions.py | 251 +++++++ .../old_files/test_last_release_documents.py | 130 ++++ .../old_files/test_last_release_library.py | 95 +++ .../test_session_files_from_elsewhere.py | 84 +++ tests/screens/application/restart/__init__.py | 0 .../screens/application/restart/constants.py | 6 + tests/screens/application/restart/steps.py | 44 ++ .../screens/application/restart/test_cards.py | 69 ++ .../restart/test_converter_and_library.py | 121 ++++ .../application/restart/test_deleted_files.py | 106 +++ .../restart/test_explorer_and_tab.py | 93 +++ tests/screens/application/startup/__init__.py | 0 .../test_fresh_home.py} | 11 +- .../{ => application/startup}/test_tabs.py | 1 + tests/screens/application/test_old_files.py | 670 ------------------ tests/screens/application/test_restart.py | 394 ---------- tests/screens/conftest.py | 24 +- tests/screens/exports/conftest.py | 3 +- tests/screens/exports/instruments/__init__.py | 0 tests/screens/exports/instruments/conftest.py | 10 + .../exports/instruments/doors/__init__.py | 0 .../doors/test_doors.py} | 49 +- .../instruments/from_a_channel/__init__.py | 0 .../from_a_channel/test_taking_a_channel.py} | 42 +- .../instruments/import_files/__init__.py | 0 .../exports/instruments/import_files/cases.py | 46 ++ .../instruments/import_files/constants.py | 8 + .../exports/instruments/import_files/steps.py | 46 ++ .../import_files/test_broken_files.py | 59 ++ .../import_files}/test_import.py | 178 +---- tests/screens/exports/nsf_window/__init__.py | 0 tests/screens/exports/nsf_window/constants.py | 6 + tests/screens/exports/nsf_window/steps.py | 8 + .../test_reaches_the_file.py} | 95 +-- .../nsf_window/test_silent_channels.py | 82 +++ tests/screens/exports/progress/__init__.py | 0 tests/screens/exports/progress/cases.py | 167 +++++ tests/screens/exports/progress/constants.py | 7 + tests/screens/exports/progress/steps.py | 23 + tests/screens/exports/progress/test_cancel.py | 89 +++ .../progress/test_failure_under_window.py | 69 ++ .../screens/exports/progress/test_refusals.py | 126 ++++ .../progress/test_shortened_envelopes.py | 135 ++++ tests/screens/exports/test_progress.py | 544 -------------- tests/screens/instructions/conftest.py | 3 +- tests/screens/instructions/test_library.py | 15 +- tests/screens/instructions/test_waveform.py | 10 +- tests/screens/interface/cards/__init__.py | 0 .../test_every_card_folds.py} | 17 +- tests/screens/interface/conftest.py | 3 +- tests/screens/interface/dialogs/__init__.py | 0 tests/screens/interface/dialogs/cases.py | 76 ++ .../test_every_dialog_opens_centered.py} | 90 +-- tests/screens/interface/display/__init__.py | 0 tests/screens/interface/display/constants.py | 3 + tests/screens/interface/display/steps.py | 46 ++ .../interface/display/test_countdown.py | 63 ++ .../display/test_discard_prompt.py} | 7 +- .../test_discarded_change_not_written.py | 51 ++ .../{ => display}/test_fullscreen.py | 9 +- .../display/test_kept_size_at_close.py | 49 ++ .../interface/display/test_saved_mode.py | 114 +++ .../screens/interface/keybindings/__init__.py | 0 .../test_rebinding_undo.py} | 16 +- tests/screens/interface/menus/__init__.py | 0 tests/screens/interface/menus/conftest.py | 11 + .../interface/menus/test_popup_width.py | 71 ++ .../test_printed_keys.py} | 71 +- tests/screens/interface/palettes/__init__.py | 0 tests/screens/interface/palettes/cases.py | 18 + tests/screens/interface/palettes/conftest.py | 11 + tests/screens/interface/palettes/constants.py | 7 + tests/screens/interface/palettes/steps.py | 23 + .../test_every_held_color.py} | 94 +-- .../interface/palettes/test_twenty_swaps.py | 44 ++ tests/screens/interface/shortcuts/__init__.py | 0 tests/screens/interface/shortcuts/conftest.py | 11 + .../screens/interface/shortcuts/constants.py | 3 + tests/screens/interface/shortcuts/steps.py | 9 + .../shortcuts/test_acting_on_documents.py | 40 ++ .../shortcuts/test_opening_dialogs.py | 110 +++ .../shortcuts/test_switching_views.py | 128 ++++ tests/screens/interface/test_display.py | 228 ------ tests/screens/interface/test_shortcuts.py | 251 ------- tests/screens/main/cards/__init__.py | 0 .../test_card_layout.py} | 31 +- tests/screens/main/gathering/__init__.py | 0 tests/screens/main/gathering/conftest.py | 59 ++ tests/screens/main/gathering/constants.py | 10 + tests/screens/main/gathering/steps.py | 14 + .../main/gathering/test_card_header.py | 37 + .../main/gathering/test_folders_and_edges.py | 145 ++++ .../gathering/test_navigating_and_clicking.py | 81 +++ .../main/gathering/test_right_clicking.py | 68 ++ tests/screens/main/library/__init__.py | 0 tests/screens/main/library/steps.py | 16 + .../test_folders_and_generators.py} | 161 +---- tests/screens/main/library/test_generating.py | 140 ++++ tests/screens/main/list/__init__.py | 0 tests/screens/main/list/conftest.py | 24 + tests/screens/main/list/constants.py | 5 + tests/screens/main/list/steps.py | 27 + tests/screens/main/list/test_folders.py | 158 +++++ tests/screens/main/list/test_look.py | 75 ++ tests/screens/main/list/test_rows.py | 131 ++++ tests/screens/main/list/test_thousands.py | 124 ++++ tests/screens/main/mix/__init__.py | 0 .../test_turning_to_a_mix.py} | 31 +- tests/screens/main/row_settings/__init__.py | 0 tests/screens/main/row_settings/conftest.py | 11 + tests/screens/main/row_settings/constants.py | 6 + tests/screens/main/row_settings/steps.py | 27 + tests/screens/main/row_settings/test_boxes.py | 93 +++ .../main/row_settings/test_during_a_run.py | 93 +++ .../main/row_settings/test_hundreds.py | 72 ++ tests/screens/main/row_settings/test_keys.py | 106 +++ tests/screens/main/row_settings/test_pick.py | 117 +++ tests/screens/main/run/__init__.py | 0 tests/screens/main/run/conftest.py | 36 + tests/screens/main/run/constants.py | 11 + tests/screens/main/run/steps.py | 31 + .../screens/main/run/test_button_and_lines.py | 106 +++ tests/screens/main/run/test_destination.py | 99 +++ .../main/run/test_progress_and_failure.py | 96 +++ tests/screens/main/run/test_replacing.py | 112 +++ tests/screens/main/scan/__init__.py | 0 .../test_reading_a_folder.py} | 84 ++- tests/screens/main/test_gathering.py | 330 --------- tests/screens/main/test_list.py | 462 ------------ tests/screens/main/test_row_settings.py | 416 ----------- tests/screens/main/test_run.py | 405 ----------- tests/screens/prompts/closing/__init__.py | 0 tests/screens/prompts/closing/constants.py | 3 + .../prompts/closing/test_during_an_edit.py | 121 ++++ .../test_over_a_question.py} | 141 +--- tests/screens/prompts/conftest.py | 3 +- tests/screens/prompts/every_door/__init__.py | 0 tests/screens/prompts/every_door/cases.py | 45 ++ tests/screens/prompts/every_door/constants.py | 5 + tests/screens/prompts/every_door/steps.py | 48 ++ .../every_door/test_edited_reconstruction.py | 180 +++++ .../prompts/every_door/test_edited_sample.py | 119 ++++ .../prompts/every_door/test_instrument.py | 126 ++++ .../prompts/every_door/test_nothing_open.py | 83 +++ .../every_door/test_saved_reconstruction.py | 85 +++ tests/screens/prompts/keyboard/__init__.py | 0 tests/screens/prompts/keyboard/constants.py | 3 + tests/screens/prompts/keyboard/steps.py | 18 + .../prompts/keyboard/test_display_question.py | 56 ++ .../prompts/keyboard/test_exit_chain.py | 103 +++ .../prompts/keyboard/test_load_chain.py | 83 +++ .../keyboard/test_save_that_asks_where.py | 83 +++ tests/screens/prompts/load/__init__.py | 0 .../test_conversion_over_edits.py} | 36 +- tests/screens/prompts/modals/__init__.py | 0 tests/screens/prompts/modals/constants.py | 3 + .../prompts/modals/test_held_rebuild.py | 187 +++++ .../modals/test_run_ending_behind_a_dialog.py | 270 +++++++ .../modals/test_save_into_a_closed_folder.py | 93 +++ tests/screens/prompts/open_voice/__init__.py | 0 .../{ => open_voice}/test_open_voice.py | 34 +- tests/screens/prompts/test_every_door.py | 590 --------------- tests/screens/prompts/test_keys.py | 291 -------- .../screens/prompts/test_modal_over_modal.py | 490 ------------- tests/screens/prompts/vanished/__init__.py | 0 .../test_reconstruction_removed.py} | 17 +- tests/screens/reconstructions/conftest.py | 3 +- .../screens/reconstructions/edits/__init__.py | 0 .../test_gestures_at_once.py} | 49 +- .../reconstructions/instruments/__init__.py | 0 .../reconstructions/instruments/constants.py | 9 + .../reconstructions/instruments/steps.py | 33 + .../instruments/test_audition.py | 99 +++ .../instruments/test_note_keys.py | 101 +++ .../instruments/test_pitch_rows.py | 190 +++++ .../instruments/test_typed_sequences.py | 196 +++++ .../reconstructions/player/__init__.py | 0 .../reconstructions/player/constants.py | 3 + tests/screens/reconstructions/player/steps.py | 21 + .../player/test_browser_and_channels.py | 140 ++++ .../player/test_source_switch.py | 113 +++ .../player/test_waveform_gestures.py | 271 +++++++ .../screens/reconstructions/stems/__init__.py | 0 .../test_stems_card.py} | 42 +- .../reconstructions/test_instruments.py | 536 -------------- tests/screens/reconstructions/test_player.py | 480 ------------- tests/screens/sequencer/colors/__init__.py | 0 .../test_kind_colors.py} | 28 +- tests/screens/sequencer/conftest.py | 10 +- tests/screens/sequencer/history/__init__.py | 0 tests/screens/sequencer/history/cases.py | 46 ++ tests/screens/sequencer/history/constants.py | 6 + tests/screens/sequencer/history/steps.py | 32 + .../sequencer/history/test_envelope_drag.py | 70 ++ .../test_voice_gestures.py} | 155 +--- tests/screens/sequencer/song/__init__.py | 0 tests/screens/sequencer/song/constants.py | 5 + .../sequencer/song/test_master_gain.py | 69 ++ .../song/test_order_and_highlights.py | 89 +++ .../song/test_retuning_and_saving.py | 107 +++ .../song/test_volume_without_a_sample.py | 51 ++ tests/screens/sequencer/test_song.py | 276 -------- tests/screens/sequencer/test_tracker.py | 412 ----------- tests/screens/sequencer/tracker/__init__.py | 0 tests/screens/sequencer/tracker/constants.py | 10 + tests/screens/sequencer/tracker/steps.py | 22 + .../sequencer/tracker/test_notes_typed.py | 193 +++++ .../sequencer/tracker/test_sample_column.py | 220 ++++++ tests/screens/sequencer/voices/__init__.py | 0 .../test_voices_card.py} | 32 +- tests/suite/screens/application/__init__.py | 0 tests/suite/screens/application/boundaries.py | 20 + .../running.py} | 66 +- tests/suite/screens/application/startup.py | 16 + tests/suite/screens/boundaries/dialogs.py | 26 +- tests/suite/screens/boundaries/errors.py | 6 +- tests/suite/screens/boundaries/highlights.py | 4 +- tests/suite/screens/boundaries/spawns.py | 14 +- tests/suite/screens/checks.py | 8 +- tests/suite/screens/dearpygui/bridge.py | 1 - tests/suite/screens/dearpygui/display.py | 19 +- .../screens/dearpygui/gestures/__init__.py | 0 .../screens/dearpygui/gestures/arrival.py | 165 +++++ .../screens/dearpygui/gestures/constants.py | 7 + .../screens/dearpygui/gestures/errors.py | 2 + .../screens/dearpygui/gestures/keyboard.py | 57 ++ .../screens/dearpygui/gestures/pointer.py | 215 ++++++ .../screens/dearpygui/gestures/scrolling.py | 213 ++++++ tests/suite/screens/dearpygui/hand.py | 601 +--------------- tests/suite/screens/dearpygui/isolation.py | 3 + tests/suite/screens/dearpygui/items.py | 567 --------------- .../suite/screens/dearpygui/items/__init__.py | 0 tests/suite/screens/dearpygui/items/colors.py | 95 +++ .../suite/screens/dearpygui/items/reading.py | 178 +++++ .../suite/screens/dearpygui/items/regions.py | 133 ++++ tests/suite/screens/dearpygui/items/texts.py | 138 ++++ tests/suite/screens/dearpygui/items/types.py | 10 + .../suite/screens/dearpygui/items/viewport.py | 40 ++ tests/suite/screens/dearpygui/keys.py | 5 +- tests/suite/screens/dearpygui/reach.py | 12 +- tests/suite/screens/dearpygui/recording.py | 5 +- tests/suite/screens/dearpygui/semantic.py | 4 +- tests/suite/screens/dearpygui/windows.py | 1 + tests/suite/screens/dearpygui/xtest.py | 12 +- tests/suite/screens/holds.py | 295 -------- tests/suite/screens/holds/__init__.py | 0 tests/suite/screens/holds/base.py | 27 + tests/suite/screens/holds/constants.py | 3 + tests/suite/screens/holds/conversion.py | 94 +++ tests/suite/screens/holds/export.py | 82 +++ tests/suite/screens/holds/regeneration.py | 74 ++ tests/suite/screens/holds/scan.py | 44 ++ tests/suite/screens/holds/signal.py | 18 + tests/suite/screens/palettes.py | 3 +- tests/suite/screens/plugin.py | 323 --------- tests/suite/screens/plugin/__init__.py | 0 tests/suite/screens/plugin/constants.py | 7 + tests/suite/screens/plugin/fixtures.py | 169 +++++ tests/suite/screens/plugin/hold_fixtures.py | 74 ++ tests/suite/screens/plugin/hooks.py | 103 +++ tests/suite/screens/render_thread.py | 2 + tests/suite/screens/screen.py | 22 +- tests/suite/screens/seeds.py | 492 ------------- tests/suite/screens/seeds/__init__.py | 0 tests/suite/screens/seeds/archives.py | 96 +++ tests/suite/screens/seeds/constants.py | 3 + tests/suite/screens/seeds/libraries.py | 20 + tests/suite/screens/seeds/projects.py | 228 ++++++ tests/suite/screens/seeds/reconstructions.py | 159 +++++ tests/suite/screens/seeds/recordings.py | 44 ++ tests/suite/screens/steps/exports.py | 11 + tests/suite/screens/steps/instructions.py | 6 +- tests/suite/screens/steps/main.py | 2 +- tests/suite/screens/steps/project.py | 12 + tests/suite/screens/steps/reconstructions.py | 21 +- tests/suite/screens/steps/sequencer.py | 12 +- tests/suite/screens/views/audio_settings.py | 16 +- tests/suite/screens/views/bar_graph.py | 2 +- tests/suite/screens/views/browsers.py | 9 +- tests/suite/screens/views/context_menu.py | 26 +- tests/suite/screens/views/converter.py | 41 +- tests/suite/screens/views/display_settings.py | 11 +- tests/suite/screens/views/exports.py | 24 +- tests/suite/screens/views/history.py | 6 +- tests/suite/screens/views/instructions.py | 6 +- tests/suite/screens/views/instruments.py | 23 +- .../suite/screens/views/keyboard_settings.py | 5 +- tests/suite/screens/views/main.py | 8 +- tests/suite/screens/views/menus.py | 14 +- tests/suite/screens/views/notices.py | 1 + tests/suite/screens/views/project.py | 5 +- tests/suite/screens/views/prompts.py | 14 +- tests/suite/screens/views/reconstructions.py | 9 +- tests/suite/screens/views/sequencer.py | 7 +- tests/suite/screens/views/source.py | 6 +- tests/suite/screens/views/stems.py | 8 +- tests/suite/screens/views/tabs.py | 3 +- tests/suite/screens/views/tracker.py | 11 +- tests/suite/screens/views/waveform.py | 5 +- tests/suite/screens/vocabulary/exports.py | 5 + tests/suite/screens/vocabulary/instruments.py | 3 + tests/suite/screens/vocabulary/libraries.py | 3 + tests/suite/screens/vocabulary/settings.py | 3 + tests/suite/screens/world.py | 282 -------- tests/suite/screens/worlds/__init__.py | 0 tests/suite/screens/worlds/home.py | 79 +++ tests/suite/screens/worlds/recordings.py | 109 +++ tests/suite/screens/worlds/songs.py | 107 +++ tests/suite/screens/written.py | 4 +- 326 files changed, 13079 insertions(+), 10785 deletions(-) rename tests/screens/{dialogs => application/closing}/__init__.py (100%) create mode 100644 tests/screens/application/closing/constants.py create mode 100644 tests/screens/application/closing/steps.py rename tests/screens/application/{test_closing.py => closing/test_during_work.py} (55%) create mode 100644 tests/screens/application/closing/test_exit_chain.py create mode 100644 tests/screens/application/closing/test_idle_and_unsaved.py create mode 100644 tests/screens/application/leaving/__init__.py rename tests/screens/{test_leaving.py => application/leaving/test_exit_shortcut.py} (74%) create mode 100644 tests/screens/application/old_files/__init__.py create mode 100644 tests/screens/application/old_files/cases.py create mode 100644 tests/screens/application/old_files/constants.py create mode 100644 tests/screens/application/old_files/steps.py create mode 100644 tests/screens/application/old_files/test_broken_projects.py create mode 100644 tests/screens/application/old_files/test_broken_reconstructions.py create mode 100644 tests/screens/application/old_files/test_last_release_documents.py create mode 100644 tests/screens/application/old_files/test_last_release_library.py create mode 100644 tests/screens/application/old_files/test_session_files_from_elsewhere.py create mode 100644 tests/screens/application/restart/__init__.py create mode 100644 tests/screens/application/restart/constants.py create mode 100644 tests/screens/application/restart/steps.py create mode 100644 tests/screens/application/restart/test_cards.py create mode 100644 tests/screens/application/restart/test_converter_and_library.py create mode 100644 tests/screens/application/restart/test_deleted_files.py create mode 100644 tests/screens/application/restart/test_explorer_and_tab.py create mode 100644 tests/screens/application/startup/__init__.py rename tests/screens/application/{test_startup.py => startup/test_fresh_home.py} (78%) rename tests/screens/{ => application/startup}/test_tabs.py (89%) delete mode 100644 tests/screens/application/test_old_files.py delete mode 100644 tests/screens/application/test_restart.py create mode 100644 tests/screens/exports/instruments/__init__.py create mode 100644 tests/screens/exports/instruments/conftest.py create mode 100644 tests/screens/exports/instruments/doors/__init__.py rename tests/screens/exports/{test_instrument_doors.py => instruments/doors/test_doors.py} (84%) create mode 100644 tests/screens/exports/instruments/from_a_channel/__init__.py rename tests/screens/exports/{test_instrument_from_channel.py => instruments/from_a_channel/test_taking_a_channel.py} (81%) create mode 100644 tests/screens/exports/instruments/import_files/__init__.py create mode 100644 tests/screens/exports/instruments/import_files/cases.py create mode 100644 tests/screens/exports/instruments/import_files/constants.py create mode 100644 tests/screens/exports/instruments/import_files/steps.py create mode 100644 tests/screens/exports/instruments/import_files/test_broken_files.py rename tests/screens/exports/{ => instruments/import_files}/test_import.py (55%) create mode 100644 tests/screens/exports/nsf_window/__init__.py create mode 100644 tests/screens/exports/nsf_window/constants.py create mode 100644 tests/screens/exports/nsf_window/steps.py rename tests/screens/exports/{test_nsf_window.py => nsf_window/test_reaches_the_file.py} (62%) create mode 100644 tests/screens/exports/nsf_window/test_silent_channels.py create mode 100644 tests/screens/exports/progress/__init__.py create mode 100644 tests/screens/exports/progress/cases.py create mode 100644 tests/screens/exports/progress/constants.py create mode 100644 tests/screens/exports/progress/steps.py create mode 100644 tests/screens/exports/progress/test_cancel.py create mode 100644 tests/screens/exports/progress/test_failure_under_window.py create mode 100644 tests/screens/exports/progress/test_refusals.py create mode 100644 tests/screens/exports/progress/test_shortened_envelopes.py delete mode 100644 tests/screens/exports/test_progress.py create mode 100644 tests/screens/interface/cards/__init__.py rename tests/screens/interface/{test_cards.py => cards/test_every_card_folds.py} (84%) create mode 100644 tests/screens/interface/dialogs/__init__.py create mode 100644 tests/screens/interface/dialogs/cases.py rename tests/screens/interface/{test_dialog_sweep.py => dialogs/test_every_dialog_opens_centered.py} (55%) create mode 100644 tests/screens/interface/display/__init__.py create mode 100644 tests/screens/interface/display/constants.py create mode 100644 tests/screens/interface/display/steps.py create mode 100644 tests/screens/interface/display/test_countdown.py rename tests/screens/{dialogs/test_display_settings.py => interface/display/test_discard_prompt.py} (89%) create mode 100644 tests/screens/interface/display/test_discarded_change_not_written.py rename tests/screens/interface/{ => display}/test_fullscreen.py (87%) create mode 100644 tests/screens/interface/display/test_kept_size_at_close.py create mode 100644 tests/screens/interface/display/test_saved_mode.py create mode 100644 tests/screens/interface/keybindings/__init__.py rename tests/screens/interface/{test_keybindings.py => keybindings/test_rebinding_undo.py} (87%) create mode 100644 tests/screens/interface/menus/__init__.py create mode 100644 tests/screens/interface/menus/conftest.py create mode 100644 tests/screens/interface/menus/test_popup_width.py rename tests/screens/interface/{test_menus.py => menus/test_printed_keys.py} (58%) create mode 100644 tests/screens/interface/palettes/__init__.py create mode 100644 tests/screens/interface/palettes/cases.py create mode 100644 tests/screens/interface/palettes/conftest.py create mode 100644 tests/screens/interface/palettes/constants.py create mode 100644 tests/screens/interface/palettes/steps.py rename tests/screens/interface/{test_palettes.py => palettes/test_every_held_color.py} (53%) create mode 100644 tests/screens/interface/palettes/test_twenty_swaps.py create mode 100644 tests/screens/interface/shortcuts/__init__.py create mode 100644 tests/screens/interface/shortcuts/conftest.py create mode 100644 tests/screens/interface/shortcuts/constants.py create mode 100644 tests/screens/interface/shortcuts/steps.py create mode 100644 tests/screens/interface/shortcuts/test_acting_on_documents.py create mode 100644 tests/screens/interface/shortcuts/test_opening_dialogs.py create mode 100644 tests/screens/interface/shortcuts/test_switching_views.py delete mode 100644 tests/screens/interface/test_display.py delete mode 100644 tests/screens/interface/test_shortcuts.py create mode 100644 tests/screens/main/cards/__init__.py rename tests/screens/main/{test_cards.py => cards/test_card_layout.py} (79%) create mode 100644 tests/screens/main/gathering/__init__.py create mode 100644 tests/screens/main/gathering/conftest.py create mode 100644 tests/screens/main/gathering/constants.py create mode 100644 tests/screens/main/gathering/steps.py create mode 100644 tests/screens/main/gathering/test_card_header.py create mode 100644 tests/screens/main/gathering/test_folders_and_edges.py create mode 100644 tests/screens/main/gathering/test_navigating_and_clicking.py create mode 100644 tests/screens/main/gathering/test_right_clicking.py create mode 100644 tests/screens/main/library/__init__.py create mode 100644 tests/screens/main/library/steps.py rename tests/screens/main/{test_library.py => library/test_folders_and_generators.py} (52%) create mode 100644 tests/screens/main/library/test_generating.py create mode 100644 tests/screens/main/list/__init__.py create mode 100644 tests/screens/main/list/conftest.py create mode 100644 tests/screens/main/list/constants.py create mode 100644 tests/screens/main/list/steps.py create mode 100644 tests/screens/main/list/test_folders.py create mode 100644 tests/screens/main/list/test_look.py create mode 100644 tests/screens/main/list/test_rows.py create mode 100644 tests/screens/main/list/test_thousands.py create mode 100644 tests/screens/main/mix/__init__.py rename tests/screens/main/{test_mix.py => mix/test_turning_to_a_mix.py} (85%) create mode 100644 tests/screens/main/row_settings/__init__.py create mode 100644 tests/screens/main/row_settings/conftest.py create mode 100644 tests/screens/main/row_settings/constants.py create mode 100644 tests/screens/main/row_settings/steps.py create mode 100644 tests/screens/main/row_settings/test_boxes.py create mode 100644 tests/screens/main/row_settings/test_during_a_run.py create mode 100644 tests/screens/main/row_settings/test_hundreds.py create mode 100644 tests/screens/main/row_settings/test_keys.py create mode 100644 tests/screens/main/row_settings/test_pick.py create mode 100644 tests/screens/main/run/__init__.py create mode 100644 tests/screens/main/run/conftest.py create mode 100644 tests/screens/main/run/constants.py create mode 100644 tests/screens/main/run/steps.py create mode 100644 tests/screens/main/run/test_button_and_lines.py create mode 100644 tests/screens/main/run/test_destination.py create mode 100644 tests/screens/main/run/test_progress_and_failure.py create mode 100644 tests/screens/main/run/test_replacing.py create mode 100644 tests/screens/main/scan/__init__.py rename tests/screens/main/{test_scan.py => scan/test_reading_a_folder.py} (68%) delete mode 100644 tests/screens/main/test_gathering.py delete mode 100644 tests/screens/main/test_list.py delete mode 100644 tests/screens/main/test_row_settings.py delete mode 100644 tests/screens/main/test_run.py create mode 100644 tests/screens/prompts/closing/__init__.py create mode 100644 tests/screens/prompts/closing/constants.py create mode 100644 tests/screens/prompts/closing/test_during_an_edit.py rename tests/screens/prompts/{test_closing_over_a_modal.py => closing/test_over_a_question.py} (55%) create mode 100644 tests/screens/prompts/every_door/__init__.py create mode 100644 tests/screens/prompts/every_door/cases.py create mode 100644 tests/screens/prompts/every_door/constants.py create mode 100644 tests/screens/prompts/every_door/steps.py create mode 100644 tests/screens/prompts/every_door/test_edited_reconstruction.py create mode 100644 tests/screens/prompts/every_door/test_edited_sample.py create mode 100644 tests/screens/prompts/every_door/test_instrument.py create mode 100644 tests/screens/prompts/every_door/test_nothing_open.py create mode 100644 tests/screens/prompts/every_door/test_saved_reconstruction.py create mode 100644 tests/screens/prompts/keyboard/__init__.py create mode 100644 tests/screens/prompts/keyboard/constants.py create mode 100644 tests/screens/prompts/keyboard/steps.py create mode 100644 tests/screens/prompts/keyboard/test_display_question.py create mode 100644 tests/screens/prompts/keyboard/test_exit_chain.py create mode 100644 tests/screens/prompts/keyboard/test_load_chain.py create mode 100644 tests/screens/prompts/keyboard/test_save_that_asks_where.py create mode 100644 tests/screens/prompts/load/__init__.py rename tests/screens/prompts/{test_load_chain.py => load/test_conversion_over_edits.py} (89%) create mode 100644 tests/screens/prompts/modals/__init__.py create mode 100644 tests/screens/prompts/modals/constants.py create mode 100644 tests/screens/prompts/modals/test_held_rebuild.py create mode 100644 tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py create mode 100644 tests/screens/prompts/modals/test_save_into_a_closed_folder.py create mode 100644 tests/screens/prompts/open_voice/__init__.py rename tests/screens/prompts/{ => open_voice}/test_open_voice.py (85%) delete mode 100644 tests/screens/prompts/test_every_door.py delete mode 100644 tests/screens/prompts/test_keys.py delete mode 100644 tests/screens/prompts/test_modal_over_modal.py create mode 100644 tests/screens/prompts/vanished/__init__.py rename tests/screens/prompts/{test_vanished.py => vanished/test_reconstruction_removed.py} (90%) create mode 100644 tests/screens/reconstructions/edits/__init__.py rename tests/screens/reconstructions/{test_edits.py => edits/test_gestures_at_once.py} (82%) create mode 100644 tests/screens/reconstructions/instruments/__init__.py create mode 100644 tests/screens/reconstructions/instruments/constants.py create mode 100644 tests/screens/reconstructions/instruments/steps.py create mode 100644 tests/screens/reconstructions/instruments/test_audition.py create mode 100644 tests/screens/reconstructions/instruments/test_note_keys.py create mode 100644 tests/screens/reconstructions/instruments/test_pitch_rows.py create mode 100644 tests/screens/reconstructions/instruments/test_typed_sequences.py create mode 100644 tests/screens/reconstructions/player/__init__.py create mode 100644 tests/screens/reconstructions/player/constants.py create mode 100644 tests/screens/reconstructions/player/steps.py create mode 100644 tests/screens/reconstructions/player/test_browser_and_channels.py create mode 100644 tests/screens/reconstructions/player/test_source_switch.py create mode 100644 tests/screens/reconstructions/player/test_waveform_gestures.py create mode 100644 tests/screens/reconstructions/stems/__init__.py rename tests/screens/reconstructions/{test_stems.py => stems/test_stems_card.py} (84%) delete mode 100644 tests/screens/reconstructions/test_instruments.py delete mode 100644 tests/screens/reconstructions/test_player.py create mode 100644 tests/screens/sequencer/colors/__init__.py rename tests/screens/sequencer/{test_colors.py => colors/test_kind_colors.py} (87%) create mode 100644 tests/screens/sequencer/history/__init__.py create mode 100644 tests/screens/sequencer/history/cases.py create mode 100644 tests/screens/sequencer/history/constants.py create mode 100644 tests/screens/sequencer/history/steps.py create mode 100644 tests/screens/sequencer/history/test_envelope_drag.py rename tests/screens/sequencer/{test_history.py => history/test_voice_gestures.py} (51%) create mode 100644 tests/screens/sequencer/song/__init__.py create mode 100644 tests/screens/sequencer/song/constants.py create mode 100644 tests/screens/sequencer/song/test_master_gain.py create mode 100644 tests/screens/sequencer/song/test_order_and_highlights.py create mode 100644 tests/screens/sequencer/song/test_retuning_and_saving.py create mode 100644 tests/screens/sequencer/song/test_volume_without_a_sample.py delete mode 100644 tests/screens/sequencer/test_song.py delete mode 100644 tests/screens/sequencer/test_tracker.py create mode 100644 tests/screens/sequencer/tracker/__init__.py create mode 100644 tests/screens/sequencer/tracker/constants.py create mode 100644 tests/screens/sequencer/tracker/steps.py create mode 100644 tests/screens/sequencer/tracker/test_notes_typed.py create mode 100644 tests/screens/sequencer/tracker/test_sample_column.py create mode 100644 tests/screens/sequencer/voices/__init__.py rename tests/screens/sequencer/{test_voices.py => voices/test_voices_card.py} (88%) create mode 100644 tests/suite/screens/application/__init__.py create mode 100644 tests/suite/screens/application/boundaries.py rename tests/suite/screens/{application.py => application/running.py} (79%) create mode 100644 tests/suite/screens/application/startup.py create mode 100644 tests/suite/screens/dearpygui/gestures/__init__.py create mode 100644 tests/suite/screens/dearpygui/gestures/arrival.py create mode 100644 tests/suite/screens/dearpygui/gestures/constants.py create mode 100644 tests/suite/screens/dearpygui/gestures/errors.py create mode 100644 tests/suite/screens/dearpygui/gestures/keyboard.py create mode 100644 tests/suite/screens/dearpygui/gestures/pointer.py create mode 100644 tests/suite/screens/dearpygui/gestures/scrolling.py delete mode 100644 tests/suite/screens/dearpygui/items.py create mode 100644 tests/suite/screens/dearpygui/items/__init__.py create mode 100644 tests/suite/screens/dearpygui/items/colors.py create mode 100644 tests/suite/screens/dearpygui/items/reading.py create mode 100644 tests/suite/screens/dearpygui/items/regions.py create mode 100644 tests/suite/screens/dearpygui/items/texts.py create mode 100644 tests/suite/screens/dearpygui/items/types.py create mode 100644 tests/suite/screens/dearpygui/items/viewport.py delete mode 100644 tests/suite/screens/holds.py create mode 100644 tests/suite/screens/holds/__init__.py create mode 100644 tests/suite/screens/holds/base.py create mode 100644 tests/suite/screens/holds/constants.py create mode 100644 tests/suite/screens/holds/conversion.py create mode 100644 tests/suite/screens/holds/export.py create mode 100644 tests/suite/screens/holds/regeneration.py create mode 100644 tests/suite/screens/holds/scan.py create mode 100644 tests/suite/screens/holds/signal.py delete mode 100644 tests/suite/screens/plugin.py create mode 100644 tests/suite/screens/plugin/__init__.py create mode 100644 tests/suite/screens/plugin/constants.py create mode 100644 tests/suite/screens/plugin/fixtures.py create mode 100644 tests/suite/screens/plugin/hold_fixtures.py create mode 100644 tests/suite/screens/plugin/hooks.py delete mode 100644 tests/suite/screens/seeds.py create mode 100644 tests/suite/screens/seeds/__init__.py create mode 100644 tests/suite/screens/seeds/archives.py create mode 100644 tests/suite/screens/seeds/constants.py create mode 100644 tests/suite/screens/seeds/libraries.py create mode 100644 tests/suite/screens/seeds/projects.py create mode 100644 tests/suite/screens/seeds/reconstructions.py create mode 100644 tests/suite/screens/seeds/recordings.py create mode 100644 tests/suite/screens/steps/exports.py create mode 100644 tests/suite/screens/vocabulary/exports.py create mode 100644 tests/suite/screens/vocabulary/instruments.py create mode 100644 tests/suite/screens/vocabulary/libraries.py create mode 100644 tests/suite/screens/vocabulary/settings.py delete mode 100644 tests/suite/screens/world.py create mode 100644 tests/suite/screens/worlds/__init__.py create mode 100644 tests/suite/screens/worlds/home.py create mode 100644 tests/suite/screens/worlds/recordings.py create mode 100644 tests/suite/screens/worlds/songs.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index e0bb5a821..d9aaee5b8 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -237,16 +237,20 @@ What several files of a subject share sits in modules of the package, written on | Module | Holds | |---|---| | `constants.py` | Scalar `Final` constants and language keys | -| `cases.py` | Case dataclasses and the tables over them | +| `cases.py` | Case dataclasses, followed by the tables built from them | | `steps.py` | Steps, readings and the builders of a world | | `conftest.py` | Fixtures every file of the package uses, such as `world` and `startup` | What several areas share lives in `tests/suite/screens/vocabulary/`, one module per area: the keys of the dialogs and their messages, of the converter and of playback, and the names of the seeded recordings. A value that one file alone uses stays in that file. Constants stand directly under the imports in every -module, so a reader meets the values before the code that uses them, and a case in -`tests/integration/tooling/` holds that order. Scenario files import from `constants.py`, `cases.py`, -`steps.py` and the vocabulary, and never from one another. +module, so a reader meets the values before the code that uses them; the tables of `cases.py` follow the +types they are made of. A case in `tests/integration/tooling/` holds that order. Scenario files import from +`constants.py`, `cases.py`, `steps.py` and the vocabulary, and never from one another. + +The driver under `tests/suite/screens/` follows the same rules: a package per concern (`application`, `boundaries`, +`holds`, `plugin`, `seeds`, `worlds`, `views`, `steps`, and the DearPyGui layer with its `gestures` and `items`), +and a module per responsibility inside it. ### Docstrings @@ -281,14 +285,18 @@ picture as evidence of a look, for a pull request rather than an assertion. | Concern | Owner | |---|---| -| A scenario per process, its reports | `tests/suite/screens/dearpygui/isolation.py`, `plugin.py` | +| A scenario per process, its reports | `tests/suite/screens/dearpygui/isolation.py`, `plugin/hooks.py` | +| The fixtures a scenario asks for | `tests/suite/screens/plugin/fixtures.py`, `plugin/hold_fixtures.py` | | The render thread crossing and waiting | `tests/suite/screens/dearpygui/bridge.py` | -| What a user can reach, and the gestures | `tests/suite/screens/dearpygui/reach.py`, `hand.py`, `semantic.py` | +| What a user can reach | `tests/suite/screens/dearpygui/reach.py`, `semantic.py` | +| The gestures | `tests/suite/screens/dearpygui/hand.py` and `gestures/`: `arrival.py` (waiting and confirming), `pointer.py`, `scrolling.py`, `keyboard.py` | +| What a reading says of an item | `tests/suite/screens/dearpygui/items/`: `reading.py`, `regions.py`, `texts.py`, `colors.py`, `viewport.py` | | The display a worker draws on | `tests/suite/screens/dearpygui/display.py` | -| The application under test and how it ends | `tests/suite/screens/application.py` | +| The application under test and how it ends | `tests/suite/screens/application/` | | The stand-ins at the boundaries | `tests/suite/screens/boundaries/` | -| Work held in flight | `tests/suite/screens/holds.py` | +| Work held in flight | `tests/suite/screens/holds/` | | The after-checks | `tests/suite/screens/checks.py` | | What a scenario holds and reads | `tests/suite/screens/screen.py`, `views/`, `steps/` | -| What a scenario's home holds | `tests/suite/screens/world.py` | +| What a scenario's home holds | `tests/suite/screens/worlds/`, and the files it seeds in `seeds/` | +| The words several areas share | `tests/suite/screens/vocabulary/` | | The window manager's requests | `tests/suite/screens/dearpygui/windows.py` | diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bdbe186c4..a4595abdc 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -153,67 +153,68 @@ currently out of line. An entry leaves when the code meets the contract again. * Leaving the application on a machine that offers no output device fails. The shutdown saves the session and asks the audio device manager for its current device, and with no device selected it - raises `ValueError: No audio device selected`. The screen scenario in `tests/screens/test_leaving.py` + raises `ValueError: No audio device selected`. The screen scenario in `tests/screens/application/leaving/test_exit_shortcut.py` reproduces it as a known failure. * A project stating a data version no upgrade step reaches is refused by its shape, not by its version. `ProjectContainer.load` validates the document before `_validate_document` compares the version, so the user reads a list of validation errors instead of the version mismatch a reconstruction reports. `tests/integration/compatibility/test_project.py` and the screen scenario in - `tests/screens/application/test_old_files.py` reproduce it as known failures. + `tests/screens/application/old_files/test_broken_projects.py` reproduce it as known failures. * Leaving keeps the last dialog folder a run found deleted. A dialog opens in the nearest folder still - standing, while `state.yaml` goes on naming the deleted one. `tests/screens/application/test_restart.py` + standing, while `state.yaml` goes on naming the deleted one. `tests/screens/application/restart/test_deleted_files.py` reproduces it as a known failure. * Leaving keeps a starred file a run found deleted: `config.yaml` goes on naming it among the favorites. - `tests/screens/application/test_restart.py` reproduces it as a known failure. + `tests/screens/application/restart/test_deleted_files.py` reproduces it as a known failure. * A folder asked for while a stopped read winds down is dropped without a word. Stop closes the scan window at once while the walk runs on to its next entry, and `FolderScan.start` turns away the folder asked for in that time, though it promises that a folder asked for once the window closes is read. - `tests/screens/main/test_scan.py` reproduces it as a known failure. + `tests/screens/main/scan/test_reading_a_folder.py` reproduces it as a known failure. * A box clicked in the Converter list leaves Source settings where it stood: ticking a channel on a recording inside an open folder changes that row, while the card goes on naming the row picked - before, or New recordings. `tests/screens/main/test_row_settings.py` reproduces it as a known failure. + before, or New recordings. `tests/screens/main/row_settings/test_boxes.py` reproduces it as a known failure. * General settings stops short of the Converter's right edge while Advanced settings is put away, where it should fill the row and end where the Converter below it does. - `tests/screens/main/test_cards.py` reproduces it as a known failure. + `tests/screens/main/cards/test_card_layout.py` reproduces it as a known failure. * The Destination line names no folder a run writes into once the gathered recordings convert with different channels: it names the folder of every channel the rows use together, while each - recording goes into the folder of its own channels. `tests/screens/main/test_run.py` reproduces it as + recording goes into the folder of its own channels. `tests/screens/main/run/test_destination.py` reproduces it as a known failure. * A library folder pointed away from and back lists its library unloaded: the library loaded before reads as one that exists, where it should come back loaded. - `tests/screens/main/test_library.py` reproduces it as a known failure. + `tests/screens/main/library/test_folders_and_generators.py` reproduces it as a known failure. * A reconstruction whose file the browser removed reads as a sample of the project: the NES frequency field locks with the hint that the project sets its rate, though the document belongs to no - project. `tests/screens/prompts/test_vanished.py` reproduces it as a known failure. + project. `tests/screens/prompts/vanished/test_reconstruction_removed.py` reproduces it as a known failure. * A voice double-clicked with the second press still held opens edited. The double-click brings the Reconstructions tab forward while the button is down, and the envelope graph that comes under the - pointer draws a bar for a press that began on the Voices card. `tests/screens/prompts/test_open_voice.py` + pointer draws a bar for a press that began on the Voices card. `tests/screens/prompts/open_voice/test_open_voice.py` reproduces it as a known failure. * A Keyboard settings row reads as listening once Cancel answers the reassign question, while no key reaches it: the keys pressed next are taken by nothing, and Escape closes the dialog. - `tests/screens/prompts/test_modal_over_modal.py` reproduces it as a known failure. + `tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py` reproduces it as a known failure. * Two closes before the first is answered ask twice: each close puts its question in line, so Cancel on the first brings the second, and so does a close made twice while an edit is on its way. - `tests/screens/prompts/test_closing_over_a_modal.py` reproduces it as a known failure. + `tests/screens/prompts/closing/test_over_a_question.py` and + `tests/screens/prompts/closing/test_during_an_edit.py` reproduce it as known failures. * A reconstruction whose recording is missing draws a flat original line beside the reconstruction, - where the waveform shows the approximation on its own. `tests/screens/reconstructions/test_player.py` + where the waveform shows the approximation on its own. `tests/screens/reconstructions/player/test_source_switch.py` reproduces it as a known failure. * With an instrument open on the Reconstructions tab, the note keys take Ctrl+Z, Ctrl+S and every combination ending in a note key: the instruments panel answers a key whatever modifiers are held, so - Undo, Save and the rest never reach their shortcuts. `tests/screens/reconstructions/test_instruments.py` + Undo, Save and the rest never reach their shortcuts. `tests/screens/reconstructions/instruments/test_note_keys.py` reproduces it as a known failure. * Playing the song from a tracker row leaves the Playback menu reading Play, with Stop greyed out, while - the song plays: that path never refreshes the menu. `tests/screens/sequencer/test_tracker.py` reproduces + the song plays: that path never refreshes the menu. `tests/screens/sequencer/tracker/test_notes_typed.py` reproduces it as a known failure. * New instrument with no project open writes into a project nobody opened: the Voices card's button has no open-project guard, so the instrument is added and listed, while Voice โ–ธ New instrument stands - greyed out. `tests/screens/sequencer/test_voices.py` reproduces it as a known failure. + greyed out. `tests/screens/sequencer/voices/test_voices_card.py` reproduces it as a known failure. * The history lines of a renamed or a moved voice name it one way alone: a rename names the voice and not its position, and a move names the positions and not the voice. - `tests/screens/sequencer/test_history.py` reproduces it as a known failure. + `tests/screens/sequencer/history/test_voice_gestures.py` reproduces it as a known failure. * A project opened as the application starts is saved as though it had no file: `load_project_safely` leaves the session's current project unset, so Save asks for a path, or writes to whatever path an - earlier session left. `tests/screens/sequencer/test_song.py` reproduces it as a known failure. + earlier session left. `tests/screens/sequencer/song/test_retuning_and_saving.py` reproduces it as a known failure. * A voice row's hover can log an error: the Voices list rebuilds every row on each update, and a hover callback queued for a row before the rebuild reads an item that no longer exists (`_on_row_hovered`, "Item not found"). The Sequencer's screen scenarios forgive it by name; no @@ -221,12 +222,12 @@ currently out of line. An entry leaves when the code meets the contract again. * Export instrument... in a project whose samples were converted at two tunings does nothing the user can see: `voice_instrument` raises the tuning error inside the menu's callback, so no message, no save dialog and no file follow, while a Bitphase project or an NSF program of the same project stops with a - message. `tests/screens/exports/test_progress.py` reproduces it as a known failure. + message. `tests/screens/exports/progress/test_refusals.py` reproduces it as a known failure. * Closing the window while Display settings holds a window size kept on the countdown but never confirmed writes that size: leaving records the live window size, while the dialog keeps the session at the values it opened with until OK, and the window manager's close passes the open dialog by. - `tests/screens/interface/test_display.py` reproduces it as a known failure. + `tests/screens/interface/display/test_kept_size_at_close.py` reproduces it as a known failure. * Closing the window while a folder is being read crashes the process once the read ends: nothing stops the walk on exit, so it runs past the shutdown, and its report closes the reading window through DearPyGui - after the context is gone (SIGSEGV). `tests/screens/application/test_closing.py` reproduces it as a + after the context is gone (SIGSEGV). `tests/screens/application/closing/test_during_work.py` reproduces it as a known failure. diff --git a/tests/integration/tooling/test_screen_layers.py b/tests/integration/tooling/test_screen_layers.py index f757f03de..d8b6c7d83 100644 --- a/tests/integration/tooling/test_screen_layers.py +++ b/tests/integration/tooling/test_screen_layers.py @@ -39,7 +39,7 @@ class TestTheDearPyGuiLayer: def test_every_import_is_dearpygui_pytest_x11_or_the_standard_library(self) -> None: strays = [ f"{path.name} imports {module}" - for path in sorted(DEARPYGUI_LAYER_DIRECTORY.glob("*.py")) + for path in sorted(DEARPYGUI_LAYER_DIRECTORY.rglob("*.py")) for module in imported_modules(path) if not is_allowed(module) ] @@ -47,4 +47,4 @@ def test_every_import_is_dearpygui_pytest_x11_or_the_standard_library(self) -> N assert strays == [] def test_the_layer_holds_modules_to_read(self) -> None: - assert any(DEARPYGUI_LAYER_DIRECTORY.glob("*.py")) + assert any(DEARPYGUI_LAYER_DIRECTORY.rglob("*.py")) diff --git a/tests/integration/tooling/test_screen_layout.py b/tests/integration/tooling/test_screen_layout.py index f5bb3ca10..59107b496 100644 --- a/tests/integration/tooling/test_screen_layout.py +++ b/tests/integration/tooling/test_screen_layout.py @@ -1,28 +1,13 @@ import ast from pathlib import Path -from typing import Final, FrozenSet, Iterator, List +from typing import Final, Iterator, List from sampletones_shared.paths.source import REPOSITORY_ROOT from tests.suite.screens.paths import SCREEN_DRIVER_DIRECTORY, SCREENS_DIRECTORY SCENARIO_FILE_PREFIX: Final[str] = "test_" +CASES_MODULE: Final[str] = "cases.py" SCREEN_TIER_DIRECTORIES: Final[List[Path]] = [SCREENS_DIRECTORY, SCREEN_DRIVER_DIRECTORY] -NOT_YET_REORGANIZED: Final[FrozenSet[str]] = frozenset( - { - "tests/screens/application/test_old_files.py", - "tests/screens/application/test_restart.py", - "tests/screens/exports/test_import.py", - "tests/screens/exports/test_progress.py", - "tests/screens/interface/test_dialog_sweep.py", - "tests/screens/main/test_list.py", - "tests/screens/main/test_row_settings.py", - "tests/screens/prompts/test_every_door.py", - "tests/screens/sequencer/test_history.py", - "tests/suite/screens/application.py", - "tests/suite/screens/boundaries/dialogs.py", - "tests/suite/screens/dearpygui/display.py", - } -) def screen_tier_modules() -> Iterator[Path]: @@ -62,7 +47,7 @@ def test_constants_stand_under_the_imports(self) -> None: offenders = { relative(path): constants_after_code(path) for path in screen_tier_modules() - if relative(path) not in NOT_YET_REORGANIZED and constants_after_code(path) + if path.name != CASES_MODULE and constants_after_code(path) } assert offenders == {} @@ -80,12 +65,3 @@ def test_no_module_imports_a_scenario_file(self) -> None: } assert importers == {} - - def test_the_list_of_modules_not_yet_reorganized_names_files_that_still_need_it(self) -> None: - finished = [ - entry - for entry in sorted(NOT_YET_REORGANIZED) - if not (REPOSITORY_ROOT / entry).exists() or not constants_after_code(REPOSITORY_ROOT / entry) - ] - - assert finished == [] diff --git a/tests/screens/dialogs/__init__.py b/tests/screens/application/closing/__init__.py similarity index 100% rename from tests/screens/dialogs/__init__.py rename to tests/screens/application/closing/__init__.py diff --git a/tests/screens/application/closing/constants.py b/tests/screens/application/closing/constants.py new file mode 100644 index 000000000..8ac5b6dd2 --- /dev/null +++ b/tests/screens/application/closing/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +NEW_TITLE: Final[str] = "Closing title" +CONVERSION_RUNNING: Final[str] = "global.dialog.message.exit_conversion_in_progress" +RUN_TIMEOUT_SECONDS: Final[float] = 60.0 diff --git a/tests/screens/application/closing/steps.py b/tests/screens/application/closing/steps.py new file mode 100644 index 000000000..adf339c6b --- /dev/null +++ b/tests/screens/application/closing/steps.py @@ -0,0 +1,14 @@ +from tests.screens.application.closing.constants import CONVERSION_RUNNING +from tests.suite.screens.screen import Screen +from tests.suite.screens.views.prompts import Prompt + + +def conversion_question(screen: Screen) -> Prompt: + """The prompt Exit shows while a conversion runs, in the window the reconstruction's question uses.""" + return screen.reconstructions.unsaved_prompt + + +def asks_about_the_conversion(screen: Screen) -> bool: + """Tells whether the shared prompt is shown with the words about a conversion in progress.""" + prompt = conversion_question(screen) + return prompt.is_shown() and screen.words(CONVERSION_RUNNING) in prompt.words() diff --git a/tests/screens/application/test_closing.py b/tests/screens/application/closing/test_during_work.py similarity index 55% rename from tests/screens/application/test_closing.py rename to tests/screens/application/closing/test_during_work.py index c86188d5e..773cb61ac 100644 --- a/tests/screens/application/test_closing.py +++ b/tests/screens/application/closing/test_during_work.py @@ -1,5 +1,5 @@ import operator -from typing import Final, List +from typing import Final import pytest @@ -9,36 +9,31 @@ from sampletones_core.constants.enums import ChannelName from sampletones_shared.paths.extensions import EXT_FILE_MODULE from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.application import Startup +from tests.screens.application.closing.constants import RUN_TIMEOUT_SECONDS +from tests.screens.application.closing.steps import asks_about_the_conversion, conversion_question +from tests.suite.screens.application.startup import Startup from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.holds import ConversionHold, ExportHold, RegenerationHold, ScanHold +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.holds.export import ExportHold +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.holds.scan import ScanHold from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording +from tests.suite.screens.seeds.recordings import Recording from tests.suite.screens.steps.main import gather, home_path -from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title from tests.suite.screens.steps.reconstructions import expect_open, raise_the_first_level_while_held -from tests.suite.screens.views.prompts import Prompt -from tests.suite.screens.vocabulary.converter import SCAN_PROGRESS from tests.suite.screens.vocabulary.playback import PAUSE -from tests.suite.screens.world import ( - ARRANGED_PROJECT, +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import ( BASS, OPEN_RECONSTRUCTION, PLAYABLE_RECONSTRUCTION, - SONG, - World, documents_world, lived_in_world, playing_world, - sequencer_world, ) +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world from tests.suite.screens.written import written_state -PROJECT_FILENAME: Final[str] = "Closing.stp" -SAVED_TITLE: Final[str] = "Before closing" -NEW_TITLE: Final[str] = "Closing title" -CONVERSION_RUNNING: Final[str] = "global.dialog.message.exit_conversion_in_progress" -RUN_TIMEOUT_SECONDS: Final[float] = 60.0 SETTLING_FRAMES: Final[int] = 30 FOLDER: Final[str] = "Many" FOLDER_RECORDINGS: Final[int] = 130 @@ -46,96 +41,10 @@ RECORDING_FREQUENCY: Final[float] = 330.0 -def save_a_titled_project(screen: Screen) -> None: - screen.project.create() - retitle_project(screen, SAVED_TITLE) - save_project_as(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) - - -def change_the_title(screen: Screen) -> None: - retitle_project(screen, NEW_TITLE) - - -def close_asks_first(screen: Screen) -> None: - screen.close_window() - - screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") - - -def saved_title() -> str: - return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) - - -class TestClosingTheWindow: - """The close button on the window's title bar leaves the application the way Exit does.""" - - def test_an_application_with_nothing_unsaved_leaves_at_once(self, screen: Screen) -> None: - screen.close_window() - - assert screen.wait_for_exit() - - -class TestClosingTheWindowOverAnUnsavedProject: - """Closing the window with an unsaved project asks about it first, and asks again at each close.""" - - def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: - def save_and_leave(screen: Screen) -> None: - screen.project.unsaved_prompt.save() - - assert screen.wait_for_exit() - assert saved_title() == NEW_TITLE - - screen.scenario( - save_a_titled_project, - change_the_title, - close_asks_first, - save_and_leave, - ).run() - - def test_cancel_keeps_the_application_and_the_file_and_the_next_close_asks_again(self, screen: Screen) -> None: - prompt = screen.project.unsaved_prompt - - def cancel(screen: Screen) -> None: - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the prompt gone") - - def close_again(screen: Screen) -> None: - screen.close_window() - - screen.expect(prompt.is_shown, bool, description="the unsaved project prompt again") - assert len(prompt.shown_windows()) == 1 - assert screen.is_running() - assert saved_title() == SAVED_TITLE - - def discard_and_leave(screen: Screen) -> None: - prompt.confirm() - - assert screen.wait_for_exit() - assert saved_title() == SAVED_TITLE - - screen.scenario( - save_a_titled_project, - change_the_title, - close_asks_first, - cancel, - close_again, - discard_and_leave, - ).run() - - -def conversion_question(screen: Screen) -> Prompt: - """The question exiting asks while a conversion runs, told apart from the reconstruction's by its words.""" - return screen.reconstructions.unsaved_prompt - - -def asks_about_the_conversion(screen: Screen) -> bool: - prompt = conversion_question(screen) - return prompt.is_shown() and screen.words(CONVERSION_RUNNING) in prompt.words() - - def close_and_leave(screen: Screen, tab: Tab) -> None: - """Closes the window with ``tab`` in front, waits for the application to stop, and checks the session written.""" + """Closes the window with ``tab`` in front, waits for the application to stop, and checks the session + written. + """ screen.close_window() assert screen.wait_for_exit() @@ -143,17 +52,26 @@ def close_and_leave(screen: Screen, tab: Tab) -> None: class TestClosingWhilePlaying: - """Closing the window while a reconstruction plays leaves at once, quietly, and writes the session.""" + """Closing the window while a reconstruction plays leaves at once and writes the session. + + A playable reconstruction is open at start. The scenario presses Play, waits for the Play entry to read + Pause, brings the Instructions tab forward, closes the window, and expects the application to stop with + Instructions written as the tab in front. + """ @pytest.fixture def world(self) -> World: + """The home holds a playable reconstruction and a session ready for playback.""" return playing_world() @pytest.fixture def startup(self) -> Startup: + """The application opens the playable reconstruction at start.""" return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) def test_it_leaves_while_the_sound_goes_on(self, screen: Screen) -> None: + """The window closes at once and the session names the tab in front.""" + def play(screen: Screen) -> None: expect_open(screen, PLAYABLE_RECONSTRUCTION) screen.press_shortcut(ShortcutId.PLAY) @@ -167,15 +85,22 @@ def close(screen: Screen) -> None: class TestClosingDuringAConversion: - """Closing the window while a conversion runs asks first: Cancel keeps both going, Exit stops the run and leaves, - writing nothing for it. + """Closing the window while a conversion runs asks first: Cancel keeps both going, Exit stops the run and + leaves. + + The scenario gathers a recording, presses the converter's action, and waits for progress. The first + close asks about the run; Cancel takes the question back and leaves the run going. The second close + asks again; Exit stops the run and leaves, and the session is written with the Main tab in front and no + reconstruction in the reconstructions folder. """ @pytest.fixture def world(self) -> World: + """The home holds the documents world: recordings to gather and stored documents.""" return documents_world() def test_it_asks_and_then_stops_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Cancel keeps the run and the application; Exit leaves with the run's output absent.""" converter = screen.main.converter prompt = conversion_question(screen) @@ -215,10 +140,16 @@ def exit_stops_it_and_leaves(screen: Screen) -> None: class TestClosingDuringAFolderRead: - """Closing the window while a folder is read leaves, the read given up, quietly, and writes the session.""" + """Closing the window while a folder is read leaves, the read given up, and writes the session. + + The home holds a folder of many short recordings. The scenario Ctrl-clicks the folder to start a read, + waits until the converter shows the scan, closes the window, and expects the application to stop with + the Main tab written. + """ @pytest.fixture def world(self) -> World: + """The home holds a lived-in session and a folder of many short recordings.""" lived_in = lived_in_world() return World( state=lived_in.state, @@ -240,6 +171,7 @@ def world(self) -> World: reason="bugs-and-todos ยง Bugs: a folder read outliving the exit calls DearPyGui after its context is gone", ) def test_it_leaves_the_read_behind(self, screen: Screen, scan_hold: ScanHold) -> None: + """The application stops after the close while the folder read is in progress.""" converter = screen.main.converter def start_reading(screen: Screen) -> None: @@ -257,17 +189,25 @@ def close(screen: Screen) -> None: class TestClosingDuringAnExport: - """Closing the window while an export runs leaves, the export stopped with no file written.""" + """Closing the window while an export runs leaves, the export stopped with no file written. + + An arranged project is open. The scenario starts a FamiTracker export whose writing is held, closes the + window, and expects the application to stop with the Sequencer tab written and the export's destination + absent. + """ @pytest.fixture def world(self) -> World: + """The home holds a Sequencer world with an arranged project.""" return sequencer_world() @pytest.fixture def startup(self) -> Startup: + """The application opens the arranged project at start.""" return Startup(reconstruction=None, project=ARRANGED_PROJECT) def test_it_leaves_and_writes_nothing(self, screen: Screen, export_hold: ExportHold) -> None: + """The application stops and the export's destination stays absent.""" destination = PROJECTS_DIRECTORY / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" def start_a_held_export(screen: Screen) -> None: @@ -286,17 +226,26 @@ def close(screen: Screen) -> None: class TestClosingDuringARebuild: - """Closing the window while an edit is on its way waits for it to land, then asks about the edited document.""" + """Closing the window while an edit is on its way waits for it to land, then asks about the edited + document. + + An open reconstruction receives an edit whose regeneration is held. The first close keeps the + application running with no question. Once the edit lands, the question about the edited reconstruction + appears; confirming it leaves. + """ @pytest.fixture def world(self) -> World: + """The home holds the documents world with a reconstruction ready to open.""" return documents_world() @pytest.fixture def startup(self) -> Startup: + """The application opens the reconstruction at start.""" return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) def test_it_waits_and_then_asks(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The close waits for the held edit, then the unsaved reconstruction prompt appears.""" prompt = screen.reconstructions.unsaved_prompt def edit_while_held(screen: Screen) -> None: @@ -319,66 +268,3 @@ def the_edit_lands_and_the_question_comes(screen: Screen) -> None: assert screen.wait_for_exit() screen.scenario(edit_while_held, close_waits_for_the_edit, the_edit_lands_and_the_question_comes).run() - - -class TestExitWithEverythingPending: - """Exit with an unsaved project, an unsaved reconstruction and a conversion running asks about each, in that - order, and leaves once the last is answered. - """ - - @pytest.fixture - def world(self) -> World: - return documents_world() - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) - - def test_the_questions_come_in_order( - self, - screen: Screen, - conversion_hold: ConversionHold, - regeneration_hold: RegenerationHold, - ) -> None: - converter = screen.main.converter - asked: List[str] = [] - - def change_both_documents_and_start_a_run(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - retitle_project(screen, NEW_TITLE) - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) - regeneration_hold.release() - screen.tabs.bring_to_front(Tab.MAIN) - gather(screen, home_path(BASS)) - converter.press_action() - screen.bridge.expect( - converter.progress, - lambda progress: progress > 0, - description="the run under way", - timeout=RUN_TIMEOUT_SECONDS, - ) - - def answer_each_question(screen: Screen) -> None: - project = screen.project.unsaved_prompt - reconstruction = screen.reconstructions.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - - screen.expect(project.is_shown, bool, description="the question about the project") - asked.append("project") - project.confirm() - screen.expect( - lambda: reconstruction.is_shown() and not asks_about_the_conversion(screen), - bool, - description="the question about the reconstruction", - ) - asked.append("reconstruction") - reconstruction.confirm() - screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") - asked.append("conversion") - conversion_question(screen).confirm() - - assert screen.wait_for_exit() - assert asked == ["project", "reconstruction", "conversion"] - - screen.scenario(change_both_documents_and_start_a_run, answer_each_question).run() diff --git a/tests/screens/application/closing/test_exit_chain.py b/tests/screens/application/closing/test_exit_chain.py new file mode 100644 index 000000000..ebb41ad96 --- /dev/null +++ b/tests/screens/application/closing/test_exit_chain.py @@ -0,0 +1,88 @@ +from typing import List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.application.closing.constants import NEW_TITLE, RUN_TIMEOUT_SECONDS +from tests.screens.application.closing.steps import asks_about_the_conversion, conversion_question +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.steps.project import retitle_project +from tests.suite.screens.steps.reconstructions import expect_open, raise_the_first_level_while_held +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import BASS, OPEN_RECONSTRUCTION, SONG, documents_world + + +class TestExitWithEverythingPending: + """Exit with an unsaved project, an unsaved reconstruction and a conversion running asks about each, in + that order, and leaves once the last is answered. + + The scenario retitles the project, edits the reconstruction, and starts a held conversion. Exit then + asks about the project, the reconstruction and the conversion in turn; each is confirmed, and the + application stops. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world: a project, a reconstruction and recordings.""" + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the reconstruction and the project at start.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_questions_come_in_order( + self, + screen: Screen, + conversion_hold: ConversionHold, + regeneration_hold: RegenerationHold, + ) -> None: + """The three questions appear as project, reconstruction, conversion.""" + converter = screen.main.converter + asked: List[str] = [] + + def change_both_documents_and_start_a_run(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + retitle_project(screen, NEW_TITLE) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + regeneration_hold.release() + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def answer_each_question(screen: Screen) -> None: + project = screen.project.unsaved_prompt + reconstruction = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project.is_shown, bool, description="the question about the project") + asked.append("project") + project.confirm() + screen.expect( + lambda: reconstruction.is_shown() and not asks_about_the_conversion(screen), + bool, + description="the question about the reconstruction", + ) + asked.append("reconstruction") + reconstruction.confirm() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + asked.append("conversion") + conversion_question(screen).confirm() + + assert screen.wait_for_exit() + assert asked == ["project", "reconstruction", "conversion"] + + screen.scenario(change_both_documents_and_start_a_run, answer_each_question).run() diff --git a/tests/screens/application/closing/test_idle_and_unsaved.py b/tests/screens/application/closing/test_idle_and_unsaved.py new file mode 100644 index 000000000..599b6179b --- /dev/null +++ b/tests/screens/application/closing/test_idle_and_unsaved.py @@ -0,0 +1,101 @@ +import operator +from typing import Final + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.application.closing.constants import NEW_TITLE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title + +PROJECT_FILENAME: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" + + +def save_a_titled_project(screen: Screen) -> None: + """Creates a project, gives it a title, and saves it in the projects folder.""" + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +def change_the_title(screen: Screen) -> None: + """Retitles the open project, leaving a change to save.""" + retitle_project(screen, NEW_TITLE) + + +def close_asks_first(screen: Screen) -> None: + """Closes the window and waits for the unsaved project prompt.""" + screen.close_window() + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + """Reads the title stored in the saved project file.""" + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +class TestClosingTheWindow: + """The close button on the window's title bar leaves the application the way Exit does.""" + + def test_an_application_with_nothing_unsaved_leaves_at_once(self, screen: Screen) -> None: + """The window closes and the application stops at once.""" + screen.close_window() + + assert screen.wait_for_exit() + + +class TestClosingTheWindowOverAnUnsavedProject: + """Closing the window with an unsaved project asks first, and asks again at each close. + + A saved project is retitled and the window closed; Save writes the new title and leaves, Cancel keeps + the file and the application, and the next close asks once more with one prompt on screen. Exit then + discards the change and leaves, with the file still holding the saved title. + """ + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_and_the_file_and_the_next_close_asks_again(self, screen: Screen) -> None: + """The file keeps its saved title through Cancel, the second prompt and the discarding exit.""" + prompt = screen.project.unsaved_prompt + + def cancel(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the prompt gone") + + def close_again(screen: Screen) -> None: + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the unsaved project prompt again") + assert len(prompt.shown_windows()) == 1 + assert screen.is_running() + assert saved_title() == SAVED_TITLE + + def discard_and_leave(screen: Screen) -> None: + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() == SAVED_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + cancel, + close_again, + discard_and_leave, + ).run() diff --git a/tests/screens/application/leaving/__init__.py b/tests/screens/application/leaving/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/test_leaving.py b/tests/screens/application/leaving/test_exit_shortcut.py similarity index 74% rename from tests/screens/test_leaving.py rename to tests/screens/application/leaving/test_exit_shortcut.py index 7c36c093a..7a847bd31 100644 --- a/tests/screens/test_leaving.py +++ b/tests/screens/application/leaving/test_exit_shortcut.py @@ -16,27 +16,38 @@ def save_a_new_project(screen: Screen) -> None: + """Creates a project and saves it in the projects folder.""" save_new_project(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) def retitle_the_project(screen: Screen) -> None: + """Retitles the open project, leaving a change to save.""" retitle_project(screen, NEW_TITLE) def ask_to_leave(screen: Screen) -> None: + """Presses the Exit shortcut and waits for the unsaved project prompt.""" screen.press_shortcut(ShortcutId.EXIT) screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") def saved_title() -> str: + """Reads the title stored in the saved project file.""" return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) class TestLeavingWithAnUnsavedProject: - """Leaving the application with changes to a saved project asks about them first.""" + """Leaving the application with changes to a saved project asks about them first. + + A new project is saved and retitled, and the Exit shortcut pressed. Save writes the new title and + leaves. Cancel keeps the application open with the file as it was; a second Exit and a confirmation + then leave with the file still holding the earlier title. + """ def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + def save_and_leave(screen: Screen) -> None: screen.project.unsaved_prompt.save() @@ -51,6 +62,8 @@ def save_and_leave(screen: Screen) -> None: ).run() def test_cancel_keeps_the_application_open_and_the_change_unsaved(self, screen: Screen) -> None: + """The application keeps running after Cancel and the file keeps its earlier title.""" + def cancel(screen: Screen) -> None: screen.project.unsaved_prompt.cancel() @@ -77,9 +90,15 @@ def leave_without_saving(screen: Screen) -> None: class TestLeavingFromTheKeyboard: - """The exit's questions answer to the keys alone, from the first press to the last.""" + """The exit's questions answer to the keys alone, from the first press to the last. + + After the Exit shortcut brings up the question, the next-control key and the activate key choose Save, + write the new title and leave. + """ def test_tab_and_enter_save_the_change_and_leave(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + def save_from_the_keyboard(screen: Screen) -> None: screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) @@ -96,10 +115,14 @@ def save_from_the_keyboard(screen: Screen) -> None: class TestLeavingWhereNothingCanPlay: - """A machine offering no output device opens the application and leaves it cleanly.""" + """A machine offering no output device opens the application and leaves it cleanly. + + The scenario waits for the Main tab and then ends the test, which closes the application. + """ @pytest.fixture def output_device(self) -> OutputDevice: + """The machine offers no output device.""" return OutputDevice.NONE @pytest.mark.xfail( @@ -108,4 +131,5 @@ def output_device(self) -> OutputDevice: reason="bugs-and-todos ยง Bugs: leaving the application where nothing can play", ) def test_the_application_leaves_without_an_error(self, screen: Screen) -> None: + """The application starts and stops cleanly with no output device.""" screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") diff --git a/tests/screens/application/old_files/__init__.py b/tests/screens/application/old_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/old_files/cases.py b/tests/screens/application/old_files/cases.py new file mode 100644 index 000000000..46babb0de --- /dev/null +++ b/tests/screens/application/old_files/cases.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +from tests.screens.application.old_files.constants import VANISHED +from tests.screens.application.old_files.steps import damaged_name +from tests.suite.screens.seeds.archives import Damage + + +@dataclass(frozen=True) +class StartupCase: + """One row of a startup table: a broken document handed over at start. + + Attributes: damage: The damage done to the file, or None for a file that is gone. + """ + + damage: Optional[Damage] + + def name(self, suffix: str) -> str: + """The file name of the row's document with ``suffix``: the damage's name, or the vanished file's.""" + return damaged_name(self.damage, suffix) if self.damage is not None else f"{VANISHED}{suffix}" + + +STARTUP_CASES: Final[Tuple[StartupCase, ...]] = (*(StartupCase(damage) for damage in Damage), StartupCase(None)) diff --git a/tests/screens/application/old_files/constants.py b/tests/screens/application/old_files/constants.py new file mode 100644 index 000000000..fe880b416 --- /dev/null +++ b/tests/screens/application/old_files/constants.py @@ -0,0 +1,11 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY + +ARCHIVED_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Archived.stn" +ARCHIVED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Archived.stp" +VANISHED: Final[str] = "vanished" +SAMPLES_FIELD: Final[str] = "samples" +NAME_FIELD: Final[str] = "name" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" diff --git a/tests/screens/application/old_files/steps.py b/tests/screens/application/old_files/steps.py new file mode 100644 index 000000000..1dcc6f774 --- /dev/null +++ b/tests/screens/application/old_files/steps.py @@ -0,0 +1,48 @@ +from typing import List + +from sampletones_core.compatibility.kind import ObjectKind +from tests.screens.application.old_files.constants import NAME_FIELD, SAMPLES_FIELD +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import Damage +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.worlds.home import HomeFile, World, screen_filling_state + + +def future_version(current: str) -> str: + """A data version one major step past ``current``.""" + major, _ = current.split(".", 1) + return f"{int(major) + 1}.0" + + +def stored_voice_names() -> List[str]: + """The voice names the archived project stores, read off its document without the application's model.""" + document = stored_document(archived(ObjectKind.PROJECT, ARCHIVED_VERSIONS[ObjectKind.PROJECT])) + return [sample[NAME_FIELD] for sample in document[SAMPLES_FIELD]] + + +def damaged_name(damage: Damage, suffix: str) -> str: + """The file name a document with ``damage`` takes, ending in ``suffix``.""" + return f"{damage.name.lower()}{suffix}" + + +def stated_version(damage: Damage, current: str, older: str) -> str: + """The version the application states when refusing a file with ``damage``: ``older`` or a future one.""" + return older if damage is Damage.OLDER_VERSION else future_version(current) + + +def world_of(*files: HomeFile) -> World: + """A home with a screen-filling session and ``files``.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=files, + ) + + +def shown_notice(screen: Screen) -> Notice: + """Waits for an error or a file-not-found notice and returns the one shown.""" + notices = (screen.error_notice, screen.file_not_found_notice) + screen.expect(lambda: any(notice.is_shown() for notice in notices), bool, description="a notice") + return next(notice for notice in notices if notice.is_shown()) diff --git a/tests/screens/application/old_files/test_broken_projects.py b/tests/screens/application/old_files/test_broken_projects.py new file mode 100644 index 000000000..165ff8f2b --- /dev/null +++ b/tests/screens/application/old_files/test_broken_projects.py @@ -0,0 +1,181 @@ +import operator +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.application.old_files.cases import STARTUP_CASES, StartupCase +from tests.screens.application.old_files.constants import ARCHIVED_PROJECT, VANISHED +from tests.screens.application.old_files.steps import ( + damaged_name, + future_version, + shown_notice, + stated_version, + stored_voice_names, + world_of, +) +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import Damage, archived_document, damaged_document +from tests.suite.screens.worlds.home import HomeFile, World + +SECOND_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Second.stp" +OLDER_PROJECT_VERSION: Final[str] = "0.9" +DOOR_SUFFIX_PROJECT: Final[str] = ".stp" +ARCHIVE_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.TRUNCATED, Damage.FOREIGN_BYTES) +VERSION_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.OLDER_VERSION, Damage.FUTURE_VERSION) + + +def damaged_project(damage: Damage) -> HomeFile: + """A damaged project file in the projects folder, one for each kind of ``damage``.""" + return damaged_document( + ObjectKind.PROJECT, + damage, + destination=PROJECTS_DIRECTORY / damaged_name(damage, ".stp"), + older_version=OLDER_PROJECT_VERSION, + future_version=future_version(SAMPLETONES_PROJECT_DATA_VERSION), + ) + + +def broken_project_world() -> World: + """A home with two sound projects, a damaged project for each damage, and a session.""" + return world_of( + archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT), + archived_document(ObjectKind.PROJECT, SECOND_PROJECT), + *(damaged_project(damage) for damage in Damage), + ) + + +def open_project(screen: Screen, path: Path) -> None: + """Chooses File > Open project with ``path`` and lets the open project be replaced.""" + project = screen.project + screen.answer_next_dialog(DialogKind.OPEN, path) + project.open() + screen.expect(project.replace_prompt.is_shown, bool, description="the question about replacing the project") + project.replace_prompt.confirm() + + +def refused_project(screen: Screen, path: Path) -> str: + """Reads the notice refusing ``path``, dismisses it, checks the project open before stays open, and + returns the words. + """ + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(path.name) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert ARCHIVED_PROJECT.stem in screen.title() + assert screen.sequencer.voices.names() == stored_voice_names() + return words + + +def open_the_second_project_the_same_way(screen: Screen) -> None: + """Opens the second sound project through the same menu and waits for its title.""" + open_project(screen, SECOND_PROJECT) + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + + +class TestRefusingABrokenProject: + """A project that cannot be read is refused with a message naming why, and the one open stays open. + + The archived project is open at start. Each damaged or vanished file is chosen from File > Open + project, and the notice names the file. A final open of a sound project succeeds, which shows the menu + still works. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds sound projects and a damaged project for each kind of damage.""" + return broken_project_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived project at start.""" + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_a_damaged_or_missing_archive_is_refused_naming_the_file(self, screen: Screen) -> None: + """The notice names the file for each damaged archive and for a file removed after it was chosen.""" + vanished = PROJECTS_DIRECTORY / f"{VANISHED}.stp" + + def refuse_each_damage(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in ARCHIVE_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + assert path.name in words + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + open_project(screen, vanished) + + words = refused_project(screen, vanished) + + assert vanished.name in words + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_the_second_project_the_same_way, + ).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a project at a version no step reaches is refused by its shape", + ) + def test_a_version_no_step_reaches_is_refused_for_its_version(self, screen: Screen) -> None: + """The notice states the file's version and the current version.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in VERSION_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + stated = stated_version(damage, SAMPLETONES_PROJECT_DATA_VERSION, OLDER_PROJECT_VERSION) + assert stated in words and SAMPLETONES_PROJECT_DATA_VERSION in words, words + + +class TestABrokenProjectGivenAtStart: + """A project handed over at start that cannot be read is set aside quietly, as the restore promises. + + The application starts on the Main tab with that project absent from the title. File > Open project + then opens a sound project, with no window left shown. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + """One row for each kind of damage, and one for a file that is gone.""" + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + """The home holds sound projects and a damaged project for each kind of damage.""" + return broken_project_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + """The application is handed the row's project at start.""" + return Startup(reconstruction=None, project=PROJECTS_DIRECTORY / case.name(DOOR_SUFFIX_PROJECT)) + + def test_it_starts_with_no_project_open(self, screen: Screen, case: StartupCase) -> None: + """The application starts with no project open and opens a sound one afterwards.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + assert case.name(DOOR_SUFFIX_PROJECT) not in screen.title() + screen.answer_next_dialog(DialogKind.OPEN, SECOND_PROJECT) + + screen.project.open() + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + assert screen.shown_windows() == () diff --git a/tests/screens/application/old_files/test_broken_reconstructions.py b/tests/screens/application/old_files/test_broken_reconstructions.py new file mode 100644 index 000000000..ed18f4cc8 --- /dev/null +++ b/tests/screens/application/old_files/test_broken_reconstructions.py @@ -0,0 +1,251 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, Optional, Type + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION +from sampletones_shared.exceptions.reconstruction import ( + IncompatibleReconstructionVersionError, + InvalidReconstructionValuesError, +) +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.old_files.cases import STARTUP_CASES, StartupCase +from tests.screens.application.old_files.constants import ARCHIVED_RECONSTRUCTION, BY_CONFIGURATION, VANISHED +from tests.screens.application.old_files.steps import ( + damaged_name, + future_version, + shown_notice, + stated_version, + world_of, +) +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import Damage, archived_document, damaged_document +from tests.suite.screens.seeds.recordings import stored_recording +from tests.suite.screens.worlds.home import HomeFile, World + +SECOND_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +OLDER_RECONSTRUCTION_VERSION: Final[str] = "2.0" +INVALID_VALUES: Final[str] = "reconstructions.browser.message.invalid_values" +INCOMPATIBLE_VERSION: Final[str] = "reconstructions.browser.template.incompatible_version_template" +RECONSTRUCTION_NOT_FOUND: Final[str] = "reconstructions.browser.message.file_not_found" +DOOR_SUFFIX_RECONSTRUCTION: Final[str] = ".stn" + + +def damaged_reconstruction(damage: Damage) -> HomeFile: + """A damaged reconstruction file in the reconstructions folder for ``damage``.""" + return damaged_document( + ObjectKind.RECONSTRUCTION, + damage, + destination=RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, ".stn"), + older_version=OLDER_RECONSTRUCTION_VERSION, + future_version=future_version(SAMPLETONES_RECONSTRUCTION_DATA_VERSION), + ) + + +def reconstruction_refusal(screen: Screen, damage: Damage) -> str: + """The words the application uses to refuse a reconstruction with ``damage``, as the language file has + them. + """ + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + stated = stated_version(damage, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, OLDER_RECONSTRUCTION_VERSION) + return screen.words(INCOMPATIBLE_VERSION).format(stated, SAMPLETONES_RECONSTRUCTION_DATA_VERSION) + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return screen.words(INVALID_VALUES) + + +def reconstruction_failure(damage: Optional[Damage]) -> Type[BaseException]: + """The failure type the application records for a reconstruction with ``damage``, or for a file that is + gone. + """ + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + return IncompatibleReconstructionVersionError + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return InvalidReconstructionValuesError + case None: + return FileNotFoundError + + +def broken_reconstruction_world() -> World: + """A home with sound reconstructions, a recording, and a damaged reconstruction for each damage.""" + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, SECOND_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn"), + stored_recording(), + *(damaged_reconstruction(damage) for damage in Damage), + ) + + +def refused_reconstruction( + screen: Screen, + damage: Optional[Damage], + expected: str, +) -> None: + """Reads the notice refusing a reconstruction, dismisses it, and checks the reconstruction open before + stays open. + """ + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(reconstruction_failure(damage).__name__) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert words.startswith(expected), words + assert screen.reconstructions.shows_open(ARCHIVED_RECONSTRUCTION) + + +class TestRefusingABrokenReconstruction: + """A reconstruction that cannot be read is refused with a readable message, and the one open stays open. + + Each door that opens a reconstruction is walked with every kind of damage, and then with a file removed + after it was listed or chosen. The last gesture opens a sound reconstruction the same way, which shows + the door still opens what it should. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds sound reconstructions and a damaged one for each kind of damage.""" + return broken_reconstruction_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived reconstruction at start.""" + return Startup(reconstruction=ARCHIVED_RECONSTRUCTION, project=None) + + def test_through_the_browser(self, screen: Screen) -> None: + """The door is a double click on a row of the reconstructions browser.""" + browser = screen.reconstructions.browser + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def open_the_listing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + browser.open_by_click(heading) + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + row = screen.expect_item(partial(browser.file_row, path), description="its row") + + browser.double_click(row) + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_listed(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(vanished), description="its row") + vanished.unlink() + + browser.double_click(row) + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(SECOND_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect( + lambda: screen.reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + open_the_listing, + refuse_each_damage, + refuse_a_file_removed_after_it_was_listed, + open_a_sound_one_the_same_way, + ).run() + + def test_through_reconstruction_open(self, screen: Screen) -> None: + """The door is the Reconstruction > Open menu entry.""" + reconstructions = screen.reconstructions + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + screen.answer_next_dialog(DialogKind.OPEN, path) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + vanished.unlink() + screen.answer_next_dialog(DialogKind.OPEN, vanished) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_a_sound_one_the_same_way, + ).run() + + +class TestABrokenReconstructionGivenAtStart: + """A reconstruction handed over at start that cannot be read is set aside quietly, as the restore + promises. + + The application starts quietly with nothing open; Reconstruction > Open then opens a sound + reconstruction, which shows the start left the application as usable as ever. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + """One row for each kind of damage, and one for a file that is gone.""" + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + """The home holds sound reconstructions and a damaged one for each kind of damage.""" + return broken_reconstruction_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + """The application is handed the row's reconstruction at start.""" + return Startup(reconstruction=RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION), project=None) + + def test_it_starts_with_nothing_open(self, screen: Screen, case: StartupCase) -> None: + """No window shows, and a sound reconstruction opens afterwards.""" + if case.damage is None: + (RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION)).unlink(missing_ok=True) + reconstructions = screen.reconstructions + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + assert screen.shown_windows() == () diff --git a/tests/screens/application/old_files/test_last_release_documents.py b/tests/screens/application/old_files/test_last_release_documents.py new file mode 100644 index 000000000..bcf40c61d --- /dev/null +++ b/tests/screens/application/old_files/test_last_release_documents.py @@ -0,0 +1,130 @@ +from typing import Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.playback import PlaybackConfig +from sampletones_core.compatibility.kind import ObjectKind +from tests.screens.application.old_files.constants import ARCHIVED_PROJECT, ARCHIVED_RECONSTRUCTION, BY_CONFIGURATION +from tests.screens.application.old_files.steps import stored_voice_names, world_of +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import archived_document +from tests.suite.screens.seeds.recordings import stored_recording +from tests.suite.screens.worlds.home import World, screen_filling_state + + +class TestTheLastReleasesReconstruction: + """A reconstruction the last release wrote opens in this build, its recording found where it names it.""" + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's reconstruction and its recording.""" + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + stored_recording(), + ) + + def test_it_opens_from_the_browser(self, screen: Screen) -> None: + """A double click on the browser's row opens the reconstruction with no notice shown. + + The scenario opens the by-configuration listing on the Reconstructions tab and double-clicks the + row. + """ + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: reconstructions.browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + reconstructions.browser.open_by_click(heading) + row = screen.expect_item( + lambda: reconstructions.browser.file_row(ARCHIVED_RECONSTRUCTION), description="its row" + ) + + reconstructions.browser.double_click(row) + + screen.expect( + lambda: reconstructions.shows_open(ARCHIVED_RECONSTRUCTION), + bool, + description="the archived reconstruction open", + ) + assert not screen.file_not_found_notice.is_shown() + assert not screen.error_notice.is_shown() + + +class TestTheLastReleasesProject: + """A project the last release wrote opens in this build, lists its voices and plays its song. + + The project opens at start with song looping on. The Sequencer lists the stored voices, Play turns the + entry to Pause and enables Stop, and Stop returns the entry to Play. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's project and a session with song looping on.""" + looping = ApplicationConfig(playback=PlaybackConfig(loop_song=True)) + return World( + state=screen_filling_state(), + application_config=looping, + config=None, + files=(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT),), + ) + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived project at start.""" + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_it_opens_at_start_lists_its_voices_and_plays_its_song(self, screen: Screen) -> None: + """The voices match the stored ones, and playback starts and stops.""" + sequencer = screen.sequencer + playing_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PAUSE)) + stopped_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PLAY)) + + def lists_its_voices(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + def plays(screen: Screen) -> None: + assert sequencer.playback.play_entry() == stopped_entry + + sequencer.playback.play() + + screen.expect(sequencer.playback.play_entry, playing_entry.__eq__, description="Play reading Pause") + assert sequencer.playback.can_stop() + + def stops(screen: Screen) -> None: + sequencer.playback.stop() + + screen.expect(sequencer.playback.play_entry, stopped_entry.__eq__, description="Pause reading Play") + assert not sequencer.playback.can_stop() + + screen.scenario(lists_its_voices, plays, stops).run() + + +class TestOpeningTheLastReleasesProjectFromTheMenu: + """File > Open project opens a project the last release wrote.""" + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's project.""" + return world_of(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT)) + + def test_it_lists_the_stored_voices(self, screen: Screen) -> None: + """The Sequencer lists the voices the file stores.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.OPEN, ARCHIVED_PROJECT) + + screen.project.open() + + screen.expect(screen.sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + +def _menu_key(element: MenuElements) -> Tuple[Page, Panel, TextType, MenuElements]: + """The language key of a menu label.""" + return (Page.GLOBAL, Panel.MENU, TextType.LABEL, element) diff --git a/tests/screens/application/old_files/test_last_release_library.py b/tests/screens/application/old_files/test_last_release_library.py new file mode 100644 index 000000000..d1ec2b48e --- /dev/null +++ b/tests/screens/application/old_files/test_last_release_library.py @@ -0,0 +1,95 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.screens.application.old_files.steps import world_of +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document, stored_version +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import archived_document +from tests.suite.screens.vocabulary.libraries import LIBRARY_LOADED +from tests.suite.screens.worlds.home import World + +CONFIG_FIELD: Final[str] = "config" +OUTDATED_ROW: Final[str] = "instructions.library.template.library_node_outdated_template" +REBUILD_TIMEOUT_SECONDS: Final[float] = 300.0 + + +def archived_library_path() -> Path: + """Where the archived library lies in the library folder, named as its settings name a library file.""" + document = stored_document(archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY])) + config = Config(library=InstructionsLibraryConfig.model_validate(document[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +class TestTheLastReleasesLibrary: + """A library the last release built reads as out of date, and only a rebuild the user asks for replaces + it. + + The Instructions tab marks the library as outdated. Opening it asks about a rebuild; Cancel keeps the + file as it was, and the next opening asks again. Confirming rebuilds the library in place: the row + loses its mark, the file holds the current data version, and the folder holds that file alone. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's library file.""" + return world_of(archived_document(ObjectKind.LIBRARY, archived_library_path())) + + def test_cancel_leaves_it_and_rebuild_rebuilds_it_in_place(self, screen: Screen) -> None: + """Cancel keeps the file's bytes; the rebuild replaces the file in place.""" + library = screen.instructions.library + path = archived_library_path() + archived_bytes = path.read_bytes() + outdated_mark = screen.words(OUTDATED_ROW).format("") + + def library_row() -> Item: + return screen.expect_item(lambda: library.row(path.name), description="the library's row") + + def reads_out_of_date(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + assert library.tree.label(library_row()).startswith(outdated_mark) + + def cancel_leaves_it(screen: Screen) -> None: + prompt = library.rebuild_prompt + library.tree.open_by_click(library_row()) + screen.expect(prompt.is_shown, bool, description="the rebuild question") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + row = library_row() + if library.tree.is_open(row): + library.tree.open_by_click(row) + screen.expect(lambda: library.tree.is_open(row), operator.not_, description="the row closed") + library.tree.open_by_click(row) + screen.expect(prompt.is_shown, bool, description="the rebuild question asked at the next opening") + assert path.read_bytes() == archived_bytes + + def rebuild_replaces_it_in_place(screen: Screen) -> None: + name = library.tree.label(library_row()).removeprefix(outdated_mark) + + library.rebuild_prompt.confirm() + + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the rebuilt library loaded", + timeout=REBUILD_TIMEOUT_SECONDS, + ) + assert library.tree.label(library_row()) == name + assert stored_version(ObjectKind.LIBRARY, path) == SAMPLETONES_LIBRARY_DATA_VERSION + assert sorted(LIBRARY_DIRECTORY.glob(f"*{path.suffix}")) == [path] + + screen.scenario(reads_out_of_date, cancel_leaves_it, rebuild_replaces_it_in_place).run() diff --git a/tests/screens/application/old_files/test_session_files_from_elsewhere.py b/tests/screens/application/old_files/test_session_files_from_elsewhere.py new file mode 100644 index 000000000..d2de6803c --- /dev/null +++ b/tests/screens/application/old_files/test_session_files_from_elsewhere.py @@ -0,0 +1,84 @@ +import json +from pathlib import Path +from typing import Final + +import pytest +import yaml + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.state.current import Current +from sampletones_application.paths import APPLICATION_STATE_PATH +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.archives import WrittenBytes +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.home import World, screen_filling_state + +RECORDING_SECONDS: Final[float] = 0.5 +RECORDING_FREQUENCY: Final[float] = 220.0 +UNKNOWN_CARD: Final[str] = "main.retired.panel" +GONE_FOLDER: Final[str] = "Gone" +LIBRARIES_FOLDER: Final[str] = "Libraries" +VIEWPORT_FIELD: Final[str] = "viewport" +WIDTH_FIELD: Final[str] = "width" +WRONG_KIND_OF_WIDTH: Final[str] = "wide" +GENERAL_FIELD: Final[str] = "general" +MISSING_SETTING: Final[str] = "max_workers" + + +def state_from_elsewhere() -> bytes: + """A session another build left: it names a card and a folder this home lacks, and a width in words.""" + state = screen_filling_state().model_copy( + update={ + "advanced_settings": True, + "current": Current(tab=Tab.SEQUENCER), + "collapsed_cards": {UNKNOWN_CARD: True}, + "expanded_directories": [Path.cwd() / GONE_FOLDER], + } + ) + document = state.model_dump(mode="json") + document[VIEWPORT_FIELD][WIDTH_FIELD] = WRONG_KIND_OF_WIDTH + return yaml.safe_dump(document).encode() + + +def config_from_elsewhere() -> bytes: + """Reconstruction settings another build left: they name a library folder and lack a setting.""" + config = Config() + general = config.general.model_copy(update={"library_directory": str(Path.cwd() / LIBRARIES_FOLDER)}) + document = config.model_copy(update={"general": general}).model_dump(mode="json") + del document[GENERAL_FIELD][MISSING_SETTING] + return json.dumps(document).encode() + + +class TestSessionFilesFromElsewhere: + """A session naming what this home lacks, with a setting of the wrong kind, beside settings missing one. + + The application starts quietly and applies every setting the files give in a form this build reads: the + Sequencer tab is in front, Advanced settings is shown, and the library folder is the one the settings + name. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the foreign session, the foreign settings and a recording in the named library + folder. + """ + return World( + state=None, + application_config=None, + config=None, + files=( + WrittenBytes(APPLICATION_STATE_PATH, state_from_elsewhere()), + WrittenBytes(CONFIG_PATH, config_from_elsewhere()), + Recording(Path.cwd() / LIBRARIES_FOLDER / "kick.wav", RECORDING_SECONDS, RECORDING_FREQUENCY), + ), + ) + + def test_it_starts_without_a_word_and_applies_what_it_reads(self, screen: Screen) -> None: + """No window shows, and the readable settings apply.""" + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + + assert screen.shown_windows() == () + assert screen.main.advanced.is_shown() + assert screen.main.library_directory() == str(Path.cwd() / LIBRARIES_FOLDER) diff --git a/tests/screens/application/restart/__init__.py b/tests/screens/application/restart/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/restart/constants.py b/tests/screens/application/restart/constants.py new file mode 100644 index 000000000..4afe278bb --- /dev/null +++ b/tests/screens/application/restart/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +RECORDINGS_FOLDER: Final[str] = "Recordings" +RECORDING_NAME: Final[str] = "kick.wav" +RECORDING_SECONDS: Final[float] = 0.25 +RECORDING_FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/application/restart/steps.py b/tests/screens/application/restart/steps.py new file mode 100644 index 000000000..2d0cda654 --- /dev/null +++ b/tests/screens/application/restart/steps.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.restart.constants import RECORDING_FREQUENCY, RECORDING_NAME, RECORDING_SECONDS +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.home import HomeFile, World, screen_filling_state + + +def home_folder(name: str) -> Path: + """A folder of the home the application was started in.""" + return Path.cwd() / name + + +def recording_in(folder: str) -> HomeFile: + """A short recording placed in ``folder`` of the home.""" + return Recording( + destination=home_folder(folder) / RECORDING_NAME, + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + + +def leave(screen: Screen) -> None: + """Leaves through Exit, which writes the session, and waits for the application to stop.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +def world_with(state: ApplicationState, *files: HomeFile) -> World: + """A home with ``state`` as its session and ``files``.""" + return World( + state=state, + application_config=None, + config=None, + files=files, + ) + + +def state_with_advanced(shown: bool) -> ApplicationState: + """A screen-filling session with Advanced settings shown or hidden as ``shown`` says.""" + return screen_filling_state().model_copy(update={"advanced_settings": shown}) diff --git a/tests/screens/application/restart/test_cards.py b/tests/screens/application/restart/test_cards.py new file mode 100644 index 000000000..6f8a51225 --- /dev/null +++ b/tests/screens/application/restart/test_cards.py @@ -0,0 +1,69 @@ +import pytest + +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.restart.steps import leave, state_with_advanced, world_with +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_state + + +class TestTheAdvancedCardAcrossARestart: + """Whether Advanced settings stands is written as the application leaves, and read as it starts.""" + + @pytest.fixture(params=[False, True], ids=["hidden", "shown"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + """The state the session holds: hidden or shown.""" + shown: bool = request.param + return shown + + @pytest.fixture + def world(self, seeded: bool) -> World: + """The home holds a session with Advanced settings as seeded.""" + return world_with(state_with_advanced(seeded)) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + """The card is shown as the session says.""" + screen.expect(screen.main.advanced.is_shown, seeded.__eq__, description="the card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + """After the toggle shortcut and Exit, the session holds the opposite of the seeded state.""" + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, seeded.__ne__, description="the card toggled") + + leave(screen) + + assert written_state().advanced_settings is not seeded + + +class TestACollapsedCardAcrossARestart: + """A card folded into its bar is written as the application leaves, and stands folded at next start.""" + + @pytest.fixture(params=[False, True], ids=["open", "collapsed"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + """The state the session holds for the Converter card: open or collapsed.""" + collapsed: bool = request.param + return collapsed + + @pytest.fixture + def world(self, seeded: bool) -> World: + """The home holds a session with the Converter card as seeded.""" + state = screen_filling_state().model_copy(update={"collapsed_cards": {TAG_MAIN_CONVERTER_PANEL: seeded}}) + return world_with(state) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + """The Converter card is collapsed as the session says.""" + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + """After the toggle and Exit, the session holds the opposite of the seeded state.""" + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + card.toggle() + + screen.expect(card.is_collapsed, seeded.__ne__, description="the Converter card toggled") + leave(screen) + assert written_state().collapsed_cards.get(TAG_MAIN_CONVERTER_PANEL) is not seeded diff --git a/tests/screens/application/restart/test_converter_and_library.py b/tests/screens/application/restart/test_converter_and_library.py new file mode 100644 index 000000000..465e1370f --- /dev/null +++ b/tests/screens/application/restart/test_converter_and_library.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.converter import ConverterConfig +from sampletones_application.constants.output import OutputKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.screens.application.restart.constants import RECORDINGS_FOLDER +from tests.screens.application.restart.steps import home_folder, leave, recording_in, state_with_advanced +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_config + +LIBRARIES_FOLDER: Final[str] = "Libraries" +CHOSEN_CHANNELS_AT_ONCE: Final[int] = 2 +SEEDED_CHANNELS_AT_ONCE: Final[int] = 3 + + +def converter_config(output: OutputKind, channels_at_once: int) -> ConverterConfig: + """A converter setting for ``output`` that allows ``channels_at_once`` channels for each step.""" + settings = StemSettings.covering(list(DEFAULT_CHANNELS)).model_copy(update={"channel_cap": channels_at_once}) + return ConverterConfig(output=output, settings=settings) + + +class TestTheConverterSettingsAcrossARestart: + """The converter's output and its channels at once are written as the application leaves, and stand at the + next start. + + The home seeds the mixed output and three channels at once. The scenario checks both, chooses per- + recording output and two channels, and expects Exit to write the chosen values. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a session and settings with the mixed output and three channels at once.""" + return World( + state=screen_filling_state(), + application_config=ApplicationConfig( + converter=converter_config(OutputKind.MIXED, SEEDED_CHANNELS_AT_ONCE), + ), + config=None, + files=(), + ) + + def test_a_home_holding_them_shows_them_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded values show at start and the chosen values are written at exit.""" + main = screen.main + + def shows_the_seeded_ones(screen: Screen) -> None: + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the output the settings name") + assert main.lit_channels_at_once() == SEEDED_CHANNELS_AT_ONCE + + def changes_them(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + main.choose_channels_at_once(CHOSEN_CHANNELS_AT_ONCE) + + screen.expect(main.lit_channels_at_once, CHOSEN_CHANNELS_AT_ONCE.__eq__, description="the step lit") + assert main.output() is OutputKind.PER_RECORDING + + def leaving_writes_them(screen: Screen) -> None: + leave(screen) + + converter = written_application_config().converter + assert converter.output is OutputKind.PER_RECORDING + assert converter.settings.channel_cap == CHOSEN_CHANNELS_AT_ONCE + + screen.scenario(shows_the_seeded_ones, changes_them, leaving_writes_them).run() + + +class TestTheLibraryFolderAcrossARestart: + """The library folder Advanced settings points at is written as the application leaves, and named at the + next start. + + The home seeds one folder as the library folder. The scenario checks it is named, chooses another + folder through the directory dialog, and expects Exit to write the chosen folder. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds Advanced settings shown, settings naming the Libraries folder, and recordings in two + folders. + """ + config = Config() + general = config.general.model_copy(update={"library_directory": str(home_folder(LIBRARIES_FOLDER))}) + return World( + state=state_with_advanced(True), + application_config=None, + config=config.model_copy(update={"general": general}), + files=(recording_in(LIBRARIES_FOLDER), recording_in(RECORDINGS_FOLDER)), + ) + + def test_a_home_holding_it_names_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded folder shows at start and the chosen folder is written at exit.""" + main = screen.main + chosen = home_folder(RECORDINGS_FOLDER) + + def names_the_seeded_one(screen: Screen) -> None: + screen.expect( + main.library_directory, + str(home_folder(LIBRARIES_FOLDER)).__eq__, + description="the library folder the settings name", + ) + + def points_at_another(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.DIRECTORY, chosen) + + main.choose_library_directory() + + screen.expect(main.library_directory, str(chosen).__eq__, description="the chosen folder named") + + def leaving_writes_it(screen: Screen) -> None: + leave(screen) + + assert Path(written_config().general.library_directory) == chosen + + screen.scenario(names_the_seeded_one, points_at_another, leaving_writes_it).run() diff --git a/tests/screens/application/restart/test_deleted_files.py b/tests/screens/application/restart/test_deleted_files.py new file mode 100644 index 000000000..10b25350b --- /dev/null +++ b/tests/screens/application/restart/test_deleted_files.py @@ -0,0 +1,106 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.favorites import Favorites +from sampletones_application.config.session.state.current import Current +from sampletones_application.config.session.state.paths import LastPaths +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.restart.steps import home_folder, leave +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_state + +DELETED_FOLDER: Final[str] = "Deleted" +DELETED_SUBFOLDER: Final[str] = "Deeper" +DELETED_RECONSTRUCTION: Final[str] = "Deleted.stn" +DELETED_FAVORITE: Final[str] = "deleted.wav" + + +def deleted_folder() -> Path: + """The folder inside the home that the session names and the home lacks.""" + return home_folder(DELETED_FOLDER) / DELETED_SUBFOLDER + + +def deleted_reconstruction() -> Path: + """The reconstruction the session names and the home lacks.""" + return RECONSTRUCTIONS_DIRECTORY / DELETED_RECONSTRUCTION + + +def deleted_favorite() -> Path: + """The starred file the settings name and the home lacks.""" + return home_folder(DELETED_FAVORITE) + + +class TestASessionNamingDeletedFiles: + """A session naming a folder, a reconstruction and a starred file deleted since the last run. + + The application starts quietly with nothing restored, a dialog opens in the nearest folder + still standing, and leaving writes none of the three back. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a session and settings naming a folder, a reconstruction and a starred file that are + gone. + """ + state = screen_filling_state().model_copy( + update={ + "current": Current(reconstruction=deleted_reconstruction()), + "last_paths": LastPaths(reconstruction=deleted_folder()), + } + ) + return World( + state=state, + application_config=ApplicationConfig(favorites=Favorites(paths={deleted_favorite()})), + config=None, + files=(), + ) + + def test_it_restores_nothing_and_asks_in_the_nearest_standing_folder(self, screen: Screen) -> None: + """The Open dialog starts in the current folder, and no file is open.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, None) + + screen.reconstructions.open_from_menu() + + request = screen.expect( + screen.dialog_requests, + lambda requests: len(requests) == 1, + description="the open dialog", + )[0] + assert request.initial_directory == Path.cwd() + assert screen.shown_windows() == () + assert screen.reconstructions.open_file() == "" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: leaving keeps the last dialog folder a run found deleted", + ) + def test_the_session_left_names_neither_the_folder_nor_the_reconstruction(self, screen: Screen) -> None: + """The session written at exit holds no reconstruction and a different dialog folder.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + state = written_state() + assert state.current.reconstruction is None + assert state.last_paths.reconstruction != deleted_folder() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: leaving keeps a starred file a run found deleted", + ) + def test_the_settings_left_name_no_deleted_starred_file(self, screen: Screen) -> None: + """The settings written at exit leave the deleted file out of the starred paths.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + assert deleted_favorite() not in written_application_config().favorites.paths diff --git a/tests/screens/application/restart/test_explorer_and_tab.py b/tests/screens/application/restart/test_explorer_and_tab.py new file mode 100644 index 000000000..2d09688f9 --- /dev/null +++ b/tests/screens/application/restart/test_explorer_and_tab.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.state.current import Current +from tests.screens.application.restart.constants import RECORDINGS_FOLDER +from tests.screens.application.restart.steps import home_folder, leave, recording_in, world_with +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_state + +UNTOUCHED_FOLDER: Final[str] = "Untouched" + + +def explorer_world(opened: List[Path]) -> World: + """A home with ``opened`` folders expanded in the session and recordings in two folders.""" + state = screen_filling_state().model_copy(update={"expanded_directories": opened}) + return world_with(state, recording_in(RECORDINGS_FOLDER), recording_in(UNTOUCHED_FOLDER)) + + +def expect_row(screen: Screen, folder: Path) -> Item: + """Waits for the explorer's row of ``folder`` and returns it.""" + return screen.expect_item( + lambda: screen.explorer.file_row(folder), + description=f"the row of {folder.name}", + ) + + +class TestLeavingWritesAFolderOpenedInTheExplorer: + """A folder opened in the explorer is written as the application leaves; a sibling left closed is left + out. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds two folders with a recording each and a session with none opened.""" + return explorer_world([]) + + def test_the_session_names_the_opened_folder_alone(self, screen: Screen) -> None: + """The session at exit names the opened folder and leaves the sibling out.""" + explorer = screen.explorer + opened = home_folder(RECORDINGS_FOLDER) + row = expect_row(screen, opened) + assert not explorer.is_open(row) + + explorer.double_click(row) + + screen.expect(lambda: explorer.is_open(row), bool, description="the folder open") + leave(screen) + written = written_state().expanded_directories + assert opened in written + assert home_folder(UNTOUCHED_FOLDER) not in written + + +class TestAHomeHoldingAnOpenedFolderShowsIt: + """A folder the session names as opened stands open at the start; a sibling left out stands closed.""" + + @pytest.fixture + def world(self) -> World: + """The home holds two folders with a recording each and a session naming one as opened.""" + return explorer_world([home_folder(RECORDINGS_FOLDER)]) + + def test_the_named_folder_stands_open_alone(self, screen: Screen) -> None: + """The named folder is open and its sibling is closed.""" + explorer = screen.explorer + opened = expect_row(screen, home_folder(RECORDINGS_FOLDER)) + closed = expect_row(screen, home_folder(UNTOUCHED_FOLDER)) + + screen.expect(lambda: explorer.is_open(opened), bool, description="the named folder open") + assert not explorer.is_open(closed) + + +class TestTheTabInFrontAcrossARestart: + """The tab in front is written as the application leaves, and brought forward at the next start.""" + + @pytest.fixture + def world(self) -> World: + """The home holds a session with the Instructions tab in front.""" + state = screen_filling_state().model_copy(update={"current": Current(tab=Tab.INSTRUCTIONS)}) + return world_with(state) + + def test_a_home_holding_it_shows_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded tab is in front at start, and the tab brought forward is written at exit.""" + screen.expect(screen.tabs.front, Tab.INSTRUCTIONS.__eq__, description="the Instructions tab in front") + + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + leave(screen) + assert written_state().current.tab is Tab.SEQUENCER diff --git a/tests/screens/application/startup/__init__.py b/tests/screens/application/startup/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/test_startup.py b/tests/screens/application/startup/test_fresh_home.py similarity index 78% rename from tests/screens/application/test_startup.py rename to tests/screens/application/startup/test_fresh_home.py index 19599305d..e04cf9d14 100644 --- a/tests/screens/application/test_startup.py +++ b/tests/screens/application/startup/test_fresh_home.py @@ -6,15 +6,21 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.suite.screens.screen import Screen -from tests.suite.screens.world import World +from tests.suite.screens.worlds.home import World from tests.suite.screens.written import written_application_config, written_config, written_state class TestAFreshHome: - """The application starts in a home no run has used, and leaves behind what the next start reads.""" + """The application starts in a home no run has used, and leaves behind what the next start reads. + + The application opens on the folder it was started in with the Main tab in front and no window shown, + and lists the library and reconstructions folders. Exit then writes the Main tab as the session's tab, + the library folder in the settings, and the application's version. + """ @pytest.fixture def world(self) -> World: + """The home holds no session, no settings and no files.""" return World( state=None, application_config=None, @@ -23,6 +29,7 @@ def world(self) -> World: ) def test_it_opens_on_the_folder_it_was_started_in_and_leaves_its_settings(self, screen: Screen) -> None: + """The first start opens on its folder, and Exit writes the session and both settings files.""" explorer = screen.explorer def opens_on_its_folder(screen: Screen) -> None: diff --git a/tests/screens/test_tabs.py b/tests/screens/application/startup/test_tabs.py similarity index 89% rename from tests/screens/test_tabs.py rename to tests/screens/application/startup/test_tabs.py index 6f6be3310..a2465d9ae 100644 --- a/tests/screens/test_tabs.py +++ b/tests/screens/application/startup/test_tabs.py @@ -6,6 +6,7 @@ class TestTabs: """The tab bar across the window brings a tab forward at a click on its header.""" def test_each_header_brings_its_tab_forward(self, screen: Screen) -> None: + """Each header brought forward by a click puts its tab in front.""" for tab in (Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS, Tab.MAIN): screen.tabs.bring_to_front(tab) diff --git a/tests/screens/application/test_old_files.py b/tests/screens/application/test_old_files.py deleted file mode 100644 index 145620518..000000000 --- a/tests/screens/application/test_old_files.py +++ /dev/null @@ -1,670 +0,0 @@ -import json -import operator -from dataclasses import dataclass -from functools import partial -from pathlib import Path -from typing import Final, List, Optional, Tuple, Type - -import pytest -import yaml - -from sampletones_application.categories.elements.global_ import MenuElements -from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType -from sampletones_application.config.session.application.config import ApplicationConfig -from sampletones_application.config.session.application.playback import PlaybackConfig -from sampletones_application.config.session.state.current import Current -from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_core.compatibility.kind import ObjectKind -from sampletones_core.configs import Config, InstructionsLibraryConfig -from sampletones_core.fft import Window -from sampletones_core.library.key import InstructionLibraryKey -from sampletones_shared.application import ( - SAMPLETONES_LIBRARY_DATA_VERSION, - SAMPLETONES_PROJECT_DATA_VERSION, - SAMPLETONES_RECONSTRUCTION_DATA_VERSION, -) -from sampletones_shared.exceptions.reconstruction import ( - IncompatibleReconstructionVersionError, - InvalidReconstructionValuesError, -) -from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY -from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document, stored_version -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.dearpygui.items import Item -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import ( - Damage, - Recording, - WrittenBytes, - archived_document, - damaged_document, - stored_recording, -) -from tests.suite.screens.views.notices import Notice -from tests.suite.screens.world import HomeFile, World, screen_filling_state - -ARCHIVED_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Archived.stn" -SECOND_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" -ARCHIVED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Archived.stp" -SECOND_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Second.stp" -VANISHED: Final[str] = "vanished" -OLDER_RECONSTRUCTION_VERSION: Final[str] = "2.0" -OLDER_PROJECT_VERSION: Final[str] = "0.9" -RECORDING_SECONDS: Final[float] = 0.5 -RECORDING_FREQUENCY: Final[float] = 220.0 -SAMPLES_FIELD: Final[str] = "samples" -NAME_FIELD: Final[str] = "name" -CONFIG_FIELD: Final[str] = "config" -INVALID_VALUES: Final[str] = "reconstructions.browser.message.invalid_values" -INCOMPATIBLE_VERSION: Final[str] = "reconstructions.browser.template.incompatible_version_template" -RECONSTRUCTION_NOT_FOUND: Final[str] = "reconstructions.browser.message.file_not_found" -BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" - - -def future_version(current: str) -> str: - """A data version past the one this build writes, which no build has written yet.""" - major, _ = current.split(".", 1) - return f"{int(major) + 1}.0" - - -def stored_voice_names() -> List[str]: - """The voice names the archived project stores, read off its document without the application's model.""" - document = stored_document(archived(ObjectKind.PROJECT, ARCHIVED_VERSIONS[ObjectKind.PROJECT])) - return [sample[NAME_FIELD] for sample in document[SAMPLES_FIELD]] - - -def damaged_name(damage: Damage, suffix: str) -> str: - return f"{damage.name.lower()}{suffix}" - - -def damaged_reconstruction(damage: Damage) -> HomeFile: - return damaged_document( - ObjectKind.RECONSTRUCTION, - damage, - destination=RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, ".stn"), - older_version=OLDER_RECONSTRUCTION_VERSION, - future_version=future_version(SAMPLETONES_RECONSTRUCTION_DATA_VERSION), - ) - - -def damaged_project(damage: Damage) -> HomeFile: - return damaged_document( - ObjectKind.PROJECT, - damage, - destination=PROJECTS_DIRECTORY / damaged_name(damage, ".stp"), - older_version=OLDER_PROJECT_VERSION, - future_version=future_version(SAMPLETONES_PROJECT_DATA_VERSION), - ) - - -def stated_version(damage: Damage, current: str, older: str) -> str: - return older if damage is Damage.OLDER_VERSION else future_version(current) - - -def world_of(*files: HomeFile) -> World: - return World( - state=screen_filling_state(), - application_config=None, - config=None, - files=files, - ) - - -def reconstruction_refusal(screen: Screen, damage: Damage) -> str: - """What the application says refusing a reconstruction with ``damage``, as the language file words it.""" - match damage: - case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: - stated = stated_version(damage, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, OLDER_RECONSTRUCTION_VERSION) - return screen.words(INCOMPATIBLE_VERSION).format(stated, SAMPLETONES_RECONSTRUCTION_DATA_VERSION) - case Damage.TRUNCATED | Damage.FOREIGN_BYTES: - return screen.words(INVALID_VALUES) - - -def reconstruction_failure(damage: Optional[Damage]) -> Type[BaseException]: - """The failure the application records for a reconstruction with ``damage``, or for a file gone.""" - match damage: - case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: - return IncompatibleReconstructionVersionError - case Damage.TRUNCATED | Damage.FOREIGN_BYTES: - return InvalidReconstructionValuesError - case None: - return FileNotFoundError - - -def shown_notice(screen: Screen) -> Notice: - notices = (screen.error_notice, screen.file_not_found_notice) - screen.expect(lambda: any(notice.is_shown() for notice in notices), bool, description="a notice") - return next(notice for notice in notices if notice.is_shown()) - - -class TestTheLastReleasesReconstruction: - """A reconstruction the last release wrote opens in this build, its recording found where it names it.""" - - @pytest.fixture - def world(self) -> World: - return world_of( - archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), - stored_recording(), - ) - - def test_it_opens_from_the_browser(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - heading = screen.expect_item( - lambda: reconstructions.browser.heading(screen.words(BY_CONFIGURATION)), - description="the heading listing reconstructions by configuration", - ) - reconstructions.browser.open_by_click(heading) - row = screen.expect_item( - lambda: reconstructions.browser.file_row(ARCHIVED_RECONSTRUCTION), description="its row" - ) - - reconstructions.browser.double_click(row) - - screen.expect( - lambda: reconstructions.shows_open(ARCHIVED_RECONSTRUCTION), - bool, - description="the archived reconstruction open", - ) - assert not screen.file_not_found_notice.is_shown() - assert not screen.error_notice.is_shown() - - -class TestTheLastReleasesProject: - """A project the last release wrote opens in this build, lists its voices and plays its song.""" - - @pytest.fixture - def world(self) -> World: - looping = ApplicationConfig(playback=PlaybackConfig(loop_song=True)) - return World( - state=screen_filling_state(), - application_config=looping, - config=None, - files=(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT),), - ) - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=ARCHIVED_PROJECT) - - def test_it_opens_at_start_lists_its_voices_and_plays_its_song(self, screen: Screen) -> None: - sequencer = screen.sequencer - playing_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PAUSE)) - stopped_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PLAY)) - - def lists_its_voices(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - - screen.expect(sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") - - def plays(screen: Screen) -> None: - assert sequencer.playback.play_entry() == stopped_entry - - sequencer.playback.play() - - screen.expect(sequencer.playback.play_entry, playing_entry.__eq__, description="Play reading Pause") - assert sequencer.playback.can_stop() - - def stops(screen: Screen) -> None: - sequencer.playback.stop() - - screen.expect(sequencer.playback.play_entry, stopped_entry.__eq__, description="Pause reading Play") - assert not sequencer.playback.can_stop() - - screen.scenario(lists_its_voices, plays, stops).run() - - -class TestOpeningTheLastReleasesProjectFromTheMenu: - """File โ–ธ Open project opens a project the last release wrote.""" - - @pytest.fixture - def world(self) -> World: - return world_of(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT)) - - def test_it_lists_the_stored_voices(self, screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - screen.answer_next_dialog(DialogKind.OPEN, ARCHIVED_PROJECT) - - screen.project.open() - - screen.expect(screen.sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") - - -def _menu_key(element: MenuElements) -> Tuple[Page, Panel, TextType, MenuElements]: - return (Page.GLOBAL, Panel.MENU, TextType.LABEL, element) - - -DOOR_SUFFIX_RECONSTRUCTION: Final[str] = ".stn" -DOOR_SUFFIX_PROJECT: Final[str] = ".stp" - - -def broken_reconstruction_world() -> World: - return world_of( - archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), - archived_document(ObjectKind.RECONSTRUCTION, SECOND_RECONSTRUCTION), - archived_document(ObjectKind.RECONSTRUCTION, RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn"), - stored_recording(), - *(damaged_reconstruction(damage) for damage in Damage), - ) - - -def refused_reconstruction( - screen: Screen, - damage: Optional[Damage], - expected: str, -) -> None: - """Reads the notice refusing a reconstruction, dismisses it, and checks the reconstruction open before stands.""" - notice = shown_notice(screen) - words = notice.words() - screen.claim_error(reconstruction_failure(damage).__name__) - - notice.dismiss() - - screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") - assert words.startswith(expected), words - assert screen.reconstructions.shows_open(ARCHIVED_RECONSTRUCTION) - - -class TestRefusingABrokenReconstruction: - """A reconstruction that cannot be read is refused with a readable message, and the one open stays open. - - Each door that opens a reconstruction is walked with every kind of damage, and then with a file - removed after it was listed or chosen. The last gesture opens a sound reconstruction the same - way, which shows the door still opens what it should. - """ - - @pytest.fixture - def world(self) -> World: - return broken_reconstruction_world() - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=ARCHIVED_RECONSTRUCTION, project=None) - - def test_through_the_browser(self, screen: Screen) -> None: - browser = screen.reconstructions.browser - vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" - - def open_the_listing(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - heading = screen.expect_item( - lambda: browser.heading(screen.words(BY_CONFIGURATION)), - description="the heading listing reconstructions by configuration", - ) - browser.open_by_click(heading) - - def refuse_each_damage(screen: Screen) -> None: - for damage in Damage: - path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) - row = screen.expect_item(partial(browser.file_row, path), description="its row") - - browser.double_click(row) - - refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) - - def refuse_a_file_removed_after_it_was_listed(screen: Screen) -> None: - row = screen.expect_item(lambda: browser.file_row(vanished), description="its row") - vanished.unlink() - - browser.double_click(row) - - refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) - - def open_a_sound_one_the_same_way(screen: Screen) -> None: - row = screen.expect_item(lambda: browser.file_row(SECOND_RECONSTRUCTION), description="its row") - - browser.double_click(row) - - screen.expect( - lambda: screen.reconstructions.shows_open(SECOND_RECONSTRUCTION), - bool, - description="the sound reconstruction open", - ) - - screen.scenario( - open_the_listing, - refuse_each_damage, - refuse_a_file_removed_after_it_was_listed, - open_a_sound_one_the_same_way, - ).run() - - def test_through_reconstruction_open(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" - - def refuse_each_damage(screen: Screen) -> None: - for damage in Damage: - path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) - screen.answer_next_dialog(DialogKind.OPEN, path) - - reconstructions.open_from_menu() - - refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) - - def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: - vanished.unlink() - screen.answer_next_dialog(DialogKind.OPEN, vanished) - - reconstructions.open_from_menu() - - refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) - - def open_a_sound_one_the_same_way(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) - - reconstructions.open_from_menu() - - screen.expect( - lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), - bool, - description="the sound reconstruction open", - ) - - screen.scenario( - refuse_each_damage, - refuse_a_file_removed_after_it_was_chosen, - open_a_sound_one_the_same_way, - ).run() - - -@dataclass(frozen=True) -class StartupCase: - """A broken document handed to the application as it starts: the damage, or its file gone.""" - - damage: Optional[Damage] - - def name(self, suffix: str) -> str: - return damaged_name(self.damage, suffix) if self.damage is not None else f"{VANISHED}{suffix}" - - -STARTUP_CASES: Final[Tuple[StartupCase, ...]] = (*(StartupCase(damage) for damage in Damage), StartupCase(None)) - - -class TestABrokenReconstructionGivenAtStart: - """A reconstruction handed over at start that cannot be read is set aside quietly, as the restore promises. - - The application starts with nothing open and says nothing; Reconstruction โ–ธ Open then opens a - sound reconstruction, which shows the start left the application as usable as ever. - """ - - @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) - def case(self, request: pytest.FixtureRequest) -> StartupCase: - case: StartupCase = request.param - return case - - @pytest.fixture - def world(self) -> World: - return broken_reconstruction_world() - - @pytest.fixture - def startup(self, case: StartupCase) -> Startup: - return Startup(reconstruction=RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION), project=None) - - def test_it_starts_with_nothing_open(self, screen: Screen, case: StartupCase) -> None: - if case.damage is None: - (RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION)).unlink(missing_ok=True) - reconstructions = screen.reconstructions - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") - screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) - - reconstructions.open_from_menu() - - screen.expect( - lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), - bool, - description="the sound reconstruction open", - ) - assert screen.shown_windows() == () - - -ARCHIVE_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.TRUNCATED, Damage.FOREIGN_BYTES) -VERSION_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.OLDER_VERSION, Damage.FUTURE_VERSION) - - -def broken_project_world() -> World: - return world_of( - archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT), - archived_document(ObjectKind.PROJECT, SECOND_PROJECT), - *(damaged_project(damage) for damage in Damage), - ) - - -def open_project(screen: Screen, path: Path) -> None: - """Chooses File โ–ธ Open project with ``path`` and lets the open project be replaced.""" - project = screen.project - screen.answer_next_dialog(DialogKind.OPEN, path) - project.open() - screen.expect(project.replace_prompt.is_shown, bool, description="the question about replacing the project") - project.replace_prompt.confirm() - - -def refused_project(screen: Screen, path: Path) -> str: - """Reads the notice refusing ``path``, dismisses it, checks the project open before stands, returning the words.""" - notice = shown_notice(screen) - words = notice.words() - screen.claim_error(path.name) - - notice.dismiss() - - screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") - assert ARCHIVED_PROJECT.stem in screen.title() - assert screen.sequencer.voices.names() == stored_voice_names() - return words - - -def open_the_second_project_the_same_way(screen: Screen) -> None: - open_project(screen, SECOND_PROJECT) - - screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") - - -class TestRefusingABrokenProject: - """A project that cannot be read is refused with a message naming why, and the one open stays open.""" - - @pytest.fixture - def world(self) -> World: - return broken_project_world() - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=ARCHIVED_PROJECT) - - def test_a_damaged_or_missing_archive_is_refused_naming_the_file(self, screen: Screen) -> None: - vanished = PROJECTS_DIRECTORY / f"{VANISHED}.stp" - - def refuse_each_damage(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - for damage in ARCHIVE_DAMAGES: - path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) - open_project(screen, path) - - words = refused_project(screen, path) - - assert path.name in words - - def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: - open_project(screen, vanished) - - words = refused_project(screen, vanished) - - assert vanished.name in words - - screen.scenario( - refuse_each_damage, - refuse_a_file_removed_after_it_was_chosen, - open_the_second_project_the_same_way, - ).run() - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: a project at a version no step reaches is refused by its shape", - ) - def test_a_version_no_step_reaches_is_refused_for_its_version(self, screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - for damage in VERSION_DAMAGES: - path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) - open_project(screen, path) - - words = refused_project(screen, path) - - stated = stated_version(damage, SAMPLETONES_PROJECT_DATA_VERSION, OLDER_PROJECT_VERSION) - assert stated in words and SAMPLETONES_PROJECT_DATA_VERSION in words, words - - -class TestABrokenProjectGivenAtStart: - """A project handed over at start that cannot be read is set aside quietly, as the restore promises.""" - - @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) - def case(self, request: pytest.FixtureRequest) -> StartupCase: - case: StartupCase = request.param - return case - - @pytest.fixture - def world(self) -> World: - return broken_project_world() - - @pytest.fixture - def startup(self, case: StartupCase) -> Startup: - return Startup(reconstruction=None, project=PROJECTS_DIRECTORY / case.name(DOOR_SUFFIX_PROJECT)) - - def test_it_starts_with_no_project_open(self, screen: Screen, case: StartupCase) -> None: - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") - assert case.name(DOOR_SUFFIX_PROJECT) not in screen.title() - screen.answer_next_dialog(DialogKind.OPEN, SECOND_PROJECT) - - screen.project.open() - - screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") - assert screen.shown_windows() == () - - -OUTDATED_ROW: Final[str] = "instructions.library.template.library_node_outdated_template" -LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" -REBUILD_TIMEOUT_SECONDS: Final[float] = 300.0 - - -def archived_library_path() -> Path: - """Where the archived library lies in the library folder, under the name its settings give a library file.""" - document = stored_document(archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY])) - config = Config(library=InstructionsLibraryConfig.model_validate(document[CONFIG_FIELD])) - key = InstructionLibraryKey.create(config.library, Window.from_config(config)) - return LIBRARY_DIRECTORY / key.filename - - -class TestTheLastReleasesLibrary: - """A library the last release built reads as out of date, and only a rebuild the user asks for replaces it.""" - - @pytest.fixture - def world(self) -> World: - return world_of(archived_document(ObjectKind.LIBRARY, archived_library_path())) - - def test_cancel_leaves_it_and_rebuild_rebuilds_it_in_place(self, screen: Screen) -> None: - library = screen.instructions.library - path = archived_library_path() - archived_bytes = path.read_bytes() - outdated_mark = screen.words(OUTDATED_ROW).format("") - - def library_row() -> Item: - return screen.expect_item(lambda: library.row(path.name), description="the library's row") - - def reads_out_of_date(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.INSTRUCTIONS) - - assert library.tree.label(library_row()).startswith(outdated_mark) - - def cancel_leaves_it(screen: Screen) -> None: - prompt = library.rebuild_prompt - library.tree.open_by_click(library_row()) - screen.expect(prompt.is_shown, bool, description="the rebuild question") - - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - row = library_row() - if library.tree.is_open(row): - library.tree.open_by_click(row) - screen.expect(lambda: library.tree.is_open(row), operator.not_, description="the row closed") - library.tree.open_by_click(row) - screen.expect(prompt.is_shown, bool, description="the rebuild question asked at the next opening") - assert path.read_bytes() == archived_bytes - - def rebuild_replaces_it_in_place(screen: Screen) -> None: - name = library.tree.label(library_row()).removeprefix(outdated_mark) - - library.rebuild_prompt.confirm() - - screen.bridge.expect( - library.status, - screen.words(LIBRARY_LOADED).format(name).__eq__, - description="the rebuilt library loaded", - timeout=REBUILD_TIMEOUT_SECONDS, - ) - assert library.tree.label(library_row()) == name - assert stored_version(ObjectKind.LIBRARY, path) == SAMPLETONES_LIBRARY_DATA_VERSION - assert sorted(LIBRARY_DIRECTORY.glob(f"*{path.suffix}")) == [path] - - screen.scenario(reads_out_of_date, cancel_leaves_it, rebuild_replaces_it_in_place).run() - - -UNKNOWN_CARD: Final[str] = "main.retired.panel" -GONE_FOLDER: Final[str] = "Gone" -LIBRARIES_FOLDER: Final[str] = "Libraries" -VIEWPORT_FIELD: Final[str] = "viewport" -WIDTH_FIELD: Final[str] = "width" -WRONG_KIND_OF_WIDTH: Final[str] = "wide" -GENERAL_FIELD: Final[str] = "general" -MISSING_SETTING: Final[str] = "max_workers" - - -def state_from_elsewhere() -> bytes: - """A session another build left: it names a card and a folder this home lacks, and a width in words.""" - state = screen_filling_state().model_copy( - update={ - "advanced_settings": True, - "current": Current(tab=Tab.SEQUENCER), - "collapsed_cards": {UNKNOWN_CARD: True}, - "expanded_directories": [Path.cwd() / GONE_FOLDER], - } - ) - document = state.model_dump(mode="json") - document[VIEWPORT_FIELD][WIDTH_FIELD] = WRONG_KIND_OF_WIDTH - return yaml.safe_dump(document).encode() - - -def config_from_elsewhere() -> bytes: - """Reconstruction settings another build left: they name a library folder and lack a setting.""" - config = Config() - general = config.general.model_copy(update={"library_directory": str(Path.cwd() / LIBRARIES_FOLDER)}) - document = config.model_copy(update={"general": general}).model_dump(mode="json") - del document[GENERAL_FIELD][MISSING_SETTING] - return json.dumps(document).encode() - - -class TestSessionFilesFromElsewhere: - """A session naming what this home lacks, with a setting of the wrong kind, beside settings missing one. - - The application starts without a word, and every setting the files name in a form this build - reads applies. - """ - - @pytest.fixture - def world(self) -> World: - return World( - state=None, - application_config=None, - config=None, - files=( - WrittenBytes(APPLICATION_STATE_PATH, state_from_elsewhere()), - WrittenBytes(CONFIG_PATH, config_from_elsewhere()), - Recording(Path.cwd() / LIBRARIES_FOLDER / "kick.wav", RECORDING_SECONDS, RECORDING_FREQUENCY), - ), - ) - - def test_it_starts_without_a_word_and_applies_what_it_reads(self, screen: Screen) -> None: - screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") - - assert screen.shown_windows() == () - assert screen.main.advanced.is_shown() - assert screen.main.library_directory() == str(Path.cwd() / LIBRARIES_FOLDER) diff --git a/tests/screens/application/test_restart.py b/tests/screens/application/test_restart.py deleted file mode 100644 index c6fd9f868..000000000 --- a/tests/screens/application/test_restart.py +++ /dev/null @@ -1,394 +0,0 @@ -import operator -from pathlib import Path -from typing import Final, List - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.config.session.application.config import ApplicationConfig -from sampletones_application.config.session.application.converter import ConverterConfig -from sampletones_application.config.session.application.favorites import Favorites -from sampletones_application.config.session.state.current import Current -from sampletones_application.config.session.state.paths import LastPaths -from sampletones_application.config.session.state.state import ApplicationState -from sampletones_application.constants.output import OutputKind -from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.configs import Config -from sampletones_core.constants.enums import DEFAULT_CHANNELS -from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings -from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.dearpygui.items import Item -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording -from tests.suite.screens.world import HomeFile, World, screen_filling_state -from tests.suite.screens.written import written_application_config, written_config, written_state - -RECORDINGS_FOLDER: Final[str] = "Recordings" -UNTOUCHED_FOLDER: Final[str] = "Untouched" -LIBRARIES_FOLDER: Final[str] = "Libraries" -RECORDING_NAME: Final[str] = "kick.wav" -RECORDING_SECONDS: Final[float] = 0.25 -RECORDING_FREQUENCY: Final[float] = 220.0 -CHOSEN_CHANNELS_AT_ONCE: Final[int] = 2 -SEEDED_CHANNELS_AT_ONCE: Final[int] = 3 - - -def home_folder(name: str) -> Path: - """A folder of the home the application was started in.""" - return Path.cwd() / name - - -def recording_in(folder: str) -> HomeFile: - return Recording( - destination=home_folder(folder) / RECORDING_NAME, - seconds=RECORDING_SECONDS, - frequency=RECORDING_FREQUENCY, - ) - - -def leave(screen: Screen) -> None: - """Leaves through Exit, which writes the session, and waits for the application to stop.""" - screen.press_shortcut(ShortcutId.EXIT) - - assert screen.wait_for_exit() - - -def world_with(state: ApplicationState, *files: HomeFile) -> World: - return World( - state=state, - application_config=None, - config=None, - files=files, - ) - - -def state_with_advanced(shown: bool) -> ApplicationState: - return screen_filling_state().model_copy(update={"advanced_settings": shown}) - - -class TestTheAdvancedCardAcrossARestart: - """Whether Advanced settings stands is written as the application leaves, and read as it starts.""" - - @pytest.fixture(params=[False, True], ids=["hidden", "shown"]) - def seeded(self, request: pytest.FixtureRequest) -> bool: - shown: bool = request.param - return shown - - @pytest.fixture - def world(self, seeded: bool) -> World: - return world_with(state_with_advanced(seeded)) - - def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: - screen.expect(screen.main.advanced.is_shown, seeded.__eq__, description="the card as the session says") - - def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: - screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) - screen.expect(screen.main.advanced.is_shown, seeded.__ne__, description="the card toggled") - - leave(screen) - - assert written_state().advanced_settings is not seeded - - -class TestACollapsedCardAcrossARestart: - """A card folded into its bar is written as the application leaves, and stands folded at the next start.""" - - @pytest.fixture(params=[False, True], ids=["open", "collapsed"]) - def seeded(self, request: pytest.FixtureRequest) -> bool: - collapsed: bool = request.param - return collapsed - - @pytest.fixture - def world(self, seeded: bool) -> World: - state = screen_filling_state().model_copy(update={"collapsed_cards": {TAG_MAIN_CONVERTER_PANEL: seeded}}) - return world_with(state) - - def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: - card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) - - screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") - - def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: - card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) - screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") - - card.toggle() - - screen.expect(card.is_collapsed, seeded.__ne__, description="the Converter card toggled") - leave(screen) - assert written_state().collapsed_cards.get(TAG_MAIN_CONVERTER_PANEL) is not seeded - - -def explorer_world(opened: List[Path]) -> World: - state = screen_filling_state().model_copy(update={"expanded_directories": opened}) - return world_with(state, recording_in(RECORDINGS_FOLDER), recording_in(UNTOUCHED_FOLDER)) - - -def expect_row(screen: Screen, folder: Path) -> Item: - return screen.expect_item( - lambda: screen.explorer.file_row(folder), - description=f"the row of {folder.name}", - ) - - -class TestLeavingWritesAFolderOpenedInTheExplorer: - """A folder opened in the explorer is written as the application leaves; a sibling left closed is not.""" - - @pytest.fixture - def world(self) -> World: - return explorer_world([]) - - def test_the_session_names_the_opened_folder_alone(self, screen: Screen) -> None: - explorer = screen.explorer - opened = home_folder(RECORDINGS_FOLDER) - row = expect_row(screen, opened) - assert not explorer.is_open(row) - - explorer.double_click(row) - - screen.expect(lambda: explorer.is_open(row), bool, description="the folder open") - leave(screen) - written = written_state().expanded_directories - assert opened in written - assert home_folder(UNTOUCHED_FOLDER) not in written - - -class TestAHomeHoldingAnOpenedFolderShowsIt: - """A folder the session names as opened stands open at the start; a sibling it leaves out stands closed.""" - - @pytest.fixture - def world(self) -> World: - return explorer_world([home_folder(RECORDINGS_FOLDER)]) - - def test_the_named_folder_stands_open_alone(self, screen: Screen) -> None: - explorer = screen.explorer - opened = expect_row(screen, home_folder(RECORDINGS_FOLDER)) - closed = expect_row(screen, home_folder(UNTOUCHED_FOLDER)) - - screen.expect(lambda: explorer.is_open(opened), bool, description="the named folder open") - assert not explorer.is_open(closed) - - -class TestTheTabInFrontAcrossARestart: - """The tab in front is written as the application leaves, and brought forward at the next start.""" - - @pytest.fixture - def world(self) -> World: - state = screen_filling_state().model_copy(update={"current": Current(tab=Tab.INSTRUCTIONS)}) - return world_with(state) - - def test_a_home_holding_it_shows_it_and_leaving_writes_the_next(self, screen: Screen) -> None: - screen.expect(screen.tabs.front, Tab.INSTRUCTIONS.__eq__, description="the Instructions tab in front") - - screen.tabs.bring_to_front(Tab.SEQUENCER) - - screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") - leave(screen) - assert written_state().current.tab is Tab.SEQUENCER - - -def converter_config(output: OutputKind, channels_at_once: int) -> ConverterConfig: - settings = StemSettings.covering(list(DEFAULT_CHANNELS)).model_copy(update={"channel_cap": channels_at_once}) - return ConverterConfig(output=output, settings=settings) - - -class TestTheConverterSettingsAcrossARestart: - """The converter's output and its channels at once are written as the application leaves, and stand at the next start.""" - - @pytest.fixture - def world(self) -> World: - return World( - state=screen_filling_state(), - application_config=ApplicationConfig( - converter=converter_config(OutputKind.MIXED, SEEDED_CHANNELS_AT_ONCE), - ), - config=None, - files=(), - ) - - def test_a_home_holding_them_shows_them_and_leaving_writes_the_next(self, screen: Screen) -> None: - main = screen.main - - def shows_the_seeded_ones(screen: Screen) -> None: - screen.expect(main.output, OutputKind.MIXED.__eq__, description="the output the settings name") - assert main.lit_channels_at_once() == SEEDED_CHANNELS_AT_ONCE - - def changes_them(screen: Screen) -> None: - main.choose_output(OutputKind.PER_RECORDING) - main.choose_channels_at_once(CHOSEN_CHANNELS_AT_ONCE) - - screen.expect(main.lit_channels_at_once, CHOSEN_CHANNELS_AT_ONCE.__eq__, description="the step lit") - assert main.output() is OutputKind.PER_RECORDING - - def leaving_writes_them(screen: Screen) -> None: - leave(screen) - - converter = written_application_config().converter - assert converter.output is OutputKind.PER_RECORDING - assert converter.settings.channel_cap == CHOSEN_CHANNELS_AT_ONCE - - screen.scenario(shows_the_seeded_ones, changes_them, leaving_writes_them).run() - - -class TestTheLibraryFolderAcrossARestart: - """The library folder Advanced settings points at is written as the application leaves, and named at the next start.""" - - @pytest.fixture - def world(self) -> World: - config = Config() - general = config.general.model_copy(update={"library_directory": str(home_folder(LIBRARIES_FOLDER))}) - return World( - state=state_with_advanced(True), - application_config=None, - config=config.model_copy(update={"general": general}), - files=(recording_in(LIBRARIES_FOLDER), recording_in(RECORDINGS_FOLDER)), - ) - - def test_a_home_holding_it_names_it_and_leaving_writes_the_next(self, screen: Screen) -> None: - main = screen.main - chosen = home_folder(RECORDINGS_FOLDER) - - def names_the_seeded_one(screen: Screen) -> None: - screen.expect( - main.library_directory, - str(home_folder(LIBRARIES_FOLDER)).__eq__, - description="the library folder the settings name", - ) - - def points_at_another(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.DIRECTORY, chosen) - - main.choose_library_directory() - - screen.expect(main.library_directory, str(chosen).__eq__, description="the chosen folder named") - - def leaving_writes_it(screen: Screen) -> None: - leave(screen) - - assert Path(written_config().general.library_directory) == chosen - - screen.scenario(names_the_seeded_one, points_at_another, leaving_writes_it).run() - - -class TestADiscardedDisplayChange: - """A Display settings change thrown away is never written, while one confirmed is.""" - - def test_leaving_writes_only_the_confirmed_change(self, screen: Screen) -> None: - settings = screen.display_settings - prompt = settings.discard_prompt - original: List[bool] = [] - - def discard_a_change(screen: Screen) -> None: - settings.open() - screen.expect(settings.is_shown, bool, description="the Display settings dialog") - original.append(settings.vsync()) - settings.toggle_vsync() - screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") - - settings.cancel() - screen.expect(prompt.is_shown, bool, description="the discard prompt") - prompt.confirm() - - screen.expect(settings.is_shown, operator.not_, description="the dialog closed") - - def confirm_a_change(screen: Screen) -> None: - settings.open() - screen.expect(settings.is_shown, bool, description="the dialog open again") - assert settings.vsync() == original[0] - settings.toggle_vsync() - screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") - - settings.confirm() - - screen.expect(settings.is_shown, operator.not_, description="the dialog closed") - - def leaving_writes_the_confirmed_one(screen: Screen) -> None: - leave(screen) - - assert written_application_config().display.vsync is not original[0] - - screen.scenario(discard_a_change, confirm_a_change, leaving_writes_the_confirmed_one).run() - - -DELETED_FOLDER: Final[str] = "Deleted" -DELETED_SUBFOLDER: Final[str] = "Deeper" -DELETED_RECONSTRUCTION: Final[str] = "Deleted.stn" -DELETED_FAVORITE: Final[str] = "deleted.wav" - - -def deleted_folder() -> Path: - return home_folder(DELETED_FOLDER) / DELETED_SUBFOLDER - - -def deleted_reconstruction() -> Path: - return RECONSTRUCTIONS_DIRECTORY / DELETED_RECONSTRUCTION - - -def deleted_favorite() -> Path: - return home_folder(DELETED_FAVORITE) - - -class TestASessionNamingDeletedFiles: - """A session naming a folder, a reconstruction and a starred file deleted since the last run. - - The application starts with nothing restored and without a word, a dialog opens in the nearest - folder still standing, and leaving writes none of the three back. - """ - - @pytest.fixture - def world(self) -> World: - state = screen_filling_state().model_copy( - update={ - "current": Current(reconstruction=deleted_reconstruction()), - "last_paths": LastPaths(reconstruction=deleted_folder()), - } - ) - return World( - state=state, - application_config=ApplicationConfig(favorites=Favorites(paths={deleted_favorite()})), - config=None, - files=(), - ) - - def test_it_restores_nothing_and_asks_in_the_nearest_standing_folder(self, screen: Screen) -> None: - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") - screen.answer_next_dialog(DialogKind.OPEN, None) - - screen.reconstructions.open_from_menu() - - request = screen.expect( - screen.dialog_requests, - lambda requests: len(requests) == 1, - description="the open dialog", - )[0] - assert request.initial_directory == Path.cwd() - assert screen.shown_windows() == () - assert screen.reconstructions.open_file() == "" - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: leaving keeps the last dialog folder a run found deleted", - ) - def test_the_session_left_names_neither_the_folder_nor_the_reconstruction(self, screen: Screen) -> None: - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") - - leave(screen) - - state = written_state() - assert state.current.reconstruction is None - assert state.last_paths.reconstruction != deleted_folder() - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: leaving keeps a starred file a run found deleted", - ) - def test_the_settings_left_name_no_deleted_starred_file(self, screen: Screen) -> None: - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") - - leave(screen) - - assert deleted_favorite() not in written_application_config().favorites.paths diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py index d0ca62e7e..cf7791659 100644 --- a/tests/screens/conftest.py +++ b/tests/screens/conftest.py @@ -1,22 +1,26 @@ -from tests.suite.screens.plugin import ( - conversion_hold, - export_hold, +from tests.suite.screens.plugin.fixtures import ( output_device, - pytest_configure, - pytest_pyfunc_call, - pytest_runtest_protocol, - pytest_unconfigure, - regeneration_hold, - scan_hold, screen, screen_application, screen_boundaries, screen_bridge, - screen_holds, screen_render_thread, startup, world, ) +from tests.suite.screens.plugin.hold_fixtures import ( + conversion_hold, + export_hold, + regeneration_hold, + scan_hold, + screen_holds, +) +from tests.suite.screens.plugin.hooks import ( + pytest_configure, + pytest_pyfunc_call, + pytest_runtest_protocol, + pytest_unconfigure, +) __all__ = [ "conversion_hold", diff --git a/tests/screens/exports/conftest.py b/tests/screens/exports/conftest.py index 9e28b9b9c..13601a17d 100644 --- a/tests/screens/exports/conftest.py +++ b/tests/screens/exports/conftest.py @@ -1,6 +1,7 @@ import pytest -from tests.suite.screens.world import World, exporting_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import exporting_world @pytest.fixture diff --git a/tests/screens/exports/instruments/__init__.py b/tests/screens/exports/instruments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/conftest.py b/tests/screens/exports/instruments/conftest.py new file mode 100644 index 000000000..ecb5bcb2f --- /dev/null +++ b/tests/screens/exports/instruments/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """The home opens with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/exports/instruments/doors/__init__.py b/tests/screens/exports/instruments/doors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/test_instrument_doors.py b/tests/screens/exports/instruments/doors/test_doors.py similarity index 84% rename from tests/screens/exports/test_instrument_doors.py rename to tests/screens/exports/instruments/doors/test_doors.py index 94b1fa136..766f411a4 100644 --- a/tests/screens/exports/test_instrument_doors.py +++ b/tests/screens/exports/instruments/doors/test_doors.py @@ -2,26 +2,22 @@ from pathlib import Path from typing import Final, List, Optional, Tuple -import pytest - from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.formats.famitracker.instrument import read_fti from sampletones_player.specification.nsf import NSF_MAGIC from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_NSF -from tests.suite.screens.application import Startup from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.dearpygui.items import EntryReading +from tests.suite.screens.dearpygui.items.texts import EntryReading from tests.suite.screens.screen import Screen from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice, open_voice_menu -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT, NSF_TYPE +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD -EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" EXPORT_TITLE: Final[str] = "reconstructions.instruments.title.export_instrument_dialog" EXPORTED: Final[str] = "reconstructions.instruments.message.export_instrument_success" FAMITRACKER_TYPE: Final[str] = "global.dialog.filter.famitracker_instrument" BITPHASE_PRESET_TYPE: Final[str] = "global.dialog.filter.bitphase_preset" -NSF_TYPE: Final[str] = "global.dialog.filter.nsf" FTI_MAGIC: Final[bytes] = b"FTI" JSON_OPENING: Final[bytes] = b"{" LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) @@ -31,13 +27,10 @@ EXPORTS: Final[str] = "exports" -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - def folder(*parts: str) -> Path: - """A folder of the home a save dialog can answer with, which stands on the disk as a native dialog's answer does.""" + """Returns a folder of the home, created when missing, that a save dialog can answer with as a native + dialog would. + """ path = Path.cwd().joinpath(EXPORTS, *parts) path.mkdir(parents=True, exist_ok=True) return path @@ -70,18 +63,26 @@ def written(screen: Screen, path: Path) -> None: def leaving_asks_nothing(screen: Screen) -> None: - """Exits a project only exported from, which leaves at once: an export changes nothing in the project.""" + """Exits a project only exported from, which goes through at once: an export leaves the project as it + was. + """ screen.press_shortcut(ShortcutId.EXIT) assert screen.wait_for_exit() class TestTheExportEntryFollowsWhatAVoiceHolds: - """Export instrument... is one entry on a voice holding one instrument and a submenu of channels on a sample holding - several; a voice holding none greys it, and greys the Reconstructions tab's button with it. + """Export instrument... is one entry on a voice holding one instrument and a submenu of channels on a + sample holding several; a voice holding none greys it, and greys the Reconstructions tab's button + with it. + + The menus of the hand-written voice, the three-channel sample and the one-channel sample are read in + turn. Then the envelopes of the hand-written voice are emptied, and the button and the menu entry + end greyed. """ def test_one_entry_a_submenu_and_a_greyed_entry(self, screen: Screen) -> None: + """Each voice shows the entry that matches the instruments it holds.""" menu = screen.context_menu instruments = screen.reconstructions.instruments @@ -141,9 +142,14 @@ def a_voice_left_without_envelopes_greys_both(screen: Screen) -> None: class TestEveryDoorWritesTheSameBytes: - """The Reconstructions tab's button and the voice's menu write the same file for the same voice.""" + """The Reconstructions tab's button and the voice's menu write the same file for the same voice. + + The hand-written voice is exported through the button, then through the menu, into two folders. The + two files hold the same bytes. + """ def test_the_button_and_the_menu_agree(self, screen: Screen) -> None: + """The two files written for the same voice hold the same bytes.""" instruments = screen.reconstructions.instruments through_the_button = folder("button") / f"{PAD}{EXT_FILE_INSTRUMENT}" through_the_menu = folder("menu") / f"{PAD}{EXT_FILE_INSTRUMENT}" @@ -169,11 +175,16 @@ def export_through_the_menu(screen: Screen) -> None: class TestOneDialogOffersEveryType: - """One save dialog offers the three instrument types: the type picked decides the format, and the name the - instrument inside. + """One save dialog offers the three instrument types: the type picked decides the format, and the name + the instrument inside. + + The voice is exported from its menu with each type picked in turn, then with a new file name. Each + file starts as its format does, and the instrument inside the FamiTracker file takes the name of the + file. """ def test_the_picked_type_decides_the_format(self, screen: Screen) -> None: + """Each type writes its own format, and the dialog lists the three types.""" types = (FAMITRACKER_TYPE, BITPHASE_PRESET_TYPE, NSF_TYPE) offered: List[Tuple[str, ...]] = [] diff --git a/tests/screens/exports/instruments/from_a_channel/__init__.py b/tests/screens/exports/instruments/from_a_channel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/test_instrument_from_channel.py b/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py similarity index 81% rename from tests/screens/exports/test_instrument_from_channel.py rename to tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py index e38c97701..7345a406c 100644 --- a/tests/screens/exports/test_instrument_from_channel.py +++ b/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py @@ -1,17 +1,13 @@ -import operator from typing import Dict, Final, List, Tuple -import pytest - from sampletones_application.categories.hierarchy import Tab -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.naming import instrument_slice_name -from tests.suite.screens.application import Startup from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.exports import leaving_asks_nothing from tests.suite.screens.steps.reconstructions import voice_title -from tests.suite.screens.steps.sequencer import forgive_the_hover_race, open_voice, open_voice_menu -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD +from tests.suite.screens.steps.sequencer import open_voice, open_voice_menu +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD INSTRUMENT_FROM: Final[str] = "sequencer.voices.label.context_instrument_from" LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) @@ -19,23 +15,16 @@ VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - -def leaving_asks_nothing(screen: Screen) -> None: - """Exits a project every change of which was undone, which leaves at once.""" - forgive_the_hover_race(screen) - screen.press_shortcut(ShortcutId.EXIT) - - assert screen.wait_for_exit() - - class TestTheSubmenuNamesTheChannelsThatPlay: - """New instrument from names each channel a sample plays, one channel among them; a hand-written voice greys it.""" + """New instrument from names each channel a sample plays, one channel among them; a hand-written voice + greys it. + + The menus of the three-channel sample, the one-channel sample and the hand-written voice are read in + turn. + """ def test_each_channel_one_channel_and_none(self, screen: Screen) -> None: + """The submenu lists each playing channel, and the hand-written voice shows a greyed entry.""" menu = screen.context_menu def a_sample_of_three_channels_names_each(screen: Screen) -> None: @@ -70,14 +59,17 @@ def a_hand_written_voice_greys_it(screen: Screen) -> None: class TestTakingAChannel: - """Taking a channel adds ` ()` carrying that channel's envelopes and opens it; one undo takes it - away and leaves the sample as it was. + """Taking a channel adds ` ()` carrying that channel's envelopes and opens it; one + undo takes it away and leaves the sample as it was. - The undo is the History card's, since the note keys of the instrument left open take Ctrl+Z (bugs-and-todos ยง - Bugs). + The envelopes of each channel of the line are read first. Each channel is then taken from the menu, + checked and undone on the History card, since the note keys of the instrument left open take Ctrl+Z + (bugs-and-todos ยง Bugs). At the end the line plays the same envelopes and the project title shows no + unsaved change. """ def test_each_channel_of_the_line(self, screen: Screen) -> None: + """Each taken channel is listed, opened with the envelopes it played, and gone after one undo.""" voices = screen.sequencer.voices instruments = screen.reconstructions.instruments played: Dict[ChannelName, str] = {} diff --git a/tests/screens/exports/instruments/import_files/__init__.py b/tests/screens/exports/instruments/import_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/import_files/cases.py b/tests/screens/exports/instruments/import_files/cases.py new file mode 100644 index 000000000..aeac59ce5 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/cases.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +READ_FAILED: Final[str] = "Failed to read an instrument from" +NO_FILE: Final[str] = "No instrument file at" +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and hold no instrument.\n" +HALF: Final[int] = 2 + + +@dataclass(frozen=True) +class BrokenFile: + """A file the import meets broken: how it is laid in the home and what the application logs. + + Attributes: + lay: Writes the broken file at the first path, given the path of a whole exported file. + logged: The start of the error the application logs for this file. + not_found: Whether the application answers with its file-not-found message. + """ + + name: str + lay: Callable[[Path, Path], None] + logged: str + not_found: bool + + +def foreign(destination: Path, _exported: Path) -> None: + """Writes bytes of another program in place of an instrument.""" + destination.write_bytes(FOREIGN_BYTES) + + +def truncated(destination: Path, exported: Path) -> None: + """Writes the first half of the exported file.""" + content = exported.read_bytes() + destination.write_bytes(content[: len(content) // HALF]) + + +def missing(_destination: Path, _exported: Path) -> None: + """Leaves the file absent, so the import finds it gone.""" + + +BROKEN_FILES: Final[Tuple[BrokenFile, ...]] = ( + BrokenFile(name="Foreign", lay=foreign, logged=READ_FAILED, not_found=False), + BrokenFile(name="Truncated", lay=truncated, logged=READ_FAILED, not_found=False), + BrokenFile(name="Missing", lay=missing, logged=NO_FILE, not_found=True), +) diff --git a/tests/screens/exports/instruments/import_files/constants.py b/tests/screens/exports/instruments/import_files/constants.py new file mode 100644 index 000000000..d884c1828 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/constants.py @@ -0,0 +1,8 @@ +from typing import Final, List + +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +IMPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.import_instrument" +KEPT: Final[str] = "Kept" +FOLDER: Final[str] = "kept" +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] diff --git a/tests/screens/exports/instruments/import_files/steps.py b/tests/screens/exports/instruments/import_files/steps.py new file mode 100644 index 000000000..cf8b34571 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/steps.py @@ -0,0 +1,46 @@ +import operator +from pathlib import Path +from typing import List, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.screens.exports.instruments.import_files.constants import FOLDER, IMPORT_INSTRUMENT, KEPT +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice_menu +from tests.suite.screens.views.history import HistoryLine, HistorySegment +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT +from tests.suite.screens.worlds.songs import PAD + + +def kept_folder() -> Path: + """Returns the folder of the home that keeps the exported instrument files, created when missing.""" + path = Path.cwd() / FOLDER + path.mkdir(parents=True, exist_ok=True) + return path + + +def choose_import(screen: Screen) -> None: + """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.voices.right_click_below_the_rows() + screen.expect(screen.context_menu.is_shown, bool, description="the list's menu") + screen.context_menu.choose(screen.words(IMPORT_INSTRUMENT)) + + +def export_kept(screen: Screen) -> Path: + """Exports the hand-written voice from its menu to the kept folder under the name :data:`KEPT`.""" + path = kept_folder() / f"{KEPT}{EXT_FILE_INSTRUMENT}" + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, path) + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + screen.expect(notice.is_shown, bool, description="the notice of the export") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return path + + +def segments(lines: Tuple[HistoryLine, ...]) -> List[Tuple[HistorySegment, ...]]: + """Returns the segments of each history line, one tuple per line.""" + return [tuple(line.segments) for line in lines] diff --git a/tests/screens/exports/instruments/import_files/test_broken_files.py b/tests/screens/exports/instruments/import_files/test_broken_files.py new file mode 100644 index 000000000..dce776478 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/test_broken_files.py @@ -0,0 +1,59 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.screens.exports.instruments.import_files.cases import BROKEN_FILES, BrokenFile +from tests.screens.exports.instruments.import_files.constants import VOICES +from tests.screens.exports.instruments.import_files.steps import choose_import, export_kept, kept_folder, segments +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.exports import leaving_asks_nothing +from tests.suite.screens.views.history import HistorySegment + +NOT_FOUND: Final[str] = "sequencer.voices.message.instrument_not_found" + + +class TestABrokenInstrumentFile: + """A file that is no instrument, a truncated one and a missing one each give a message naming the + trouble, and leave the voices and the history as they stood. + + A whole instrument is exported first. Each broken file is then chosen on the import dialog, the + message is read and dismissed, and the voices and the history match their state before the import. + """ + + def test_each_is_reported_and_changes_nothing(self, screen: Screen) -> None: + """Each broken file is reported, and the voices and the history stay as they were.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + + def export_a_whole_file(screen: Screen) -> None: + exported.append(export_kept(screen)) + before.append(segments(history.lines())) + + def importing(broken: BrokenFile) -> None: + path = kept_folder() / f"{broken.name}{EXT_FILE_INSTRUMENT}" + broken.lay(path, exported[0]) + notice = screen.file_not_found_notice if broken.not_found else screen.error_notice + screen.answer_next_dialog(DialogKind.OPEN, path) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description=f"the message about {broken.name}") + screen.claim_error(broken.logged) + if broken.not_found: + assert screen.words(NOT_FOUND) in notice.words() + assert str(path) in notice.words() + + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the message gone") + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def import_each_broken_file(screen: Screen) -> None: + for broken in BROKEN_FILES: + importing(broken) + + screen.scenario(export_a_whole_file, import_each_broken_file, leaving_asks_nothing).run() diff --git a/tests/screens/exports/test_import.py b/tests/screens/exports/instruments/import_files/test_import.py similarity index 55% rename from tests/screens/exports/test_import.py rename to tests/screens/exports/instruments/import_files/test_import.py index a0bf48a0e..c4e253618 100644 --- a/tests/screens/exports/test_import.py +++ b/tests/screens/exports/instruments/import_files/test_import.py @@ -1,96 +1,42 @@ import operator -from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, List, Tuple - -import pytest +from typing import Final, List, Tuple from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT -from tests.suite.screens.application import Startup +from tests.screens.exports.instruments.import_files.constants import KEPT, VOICES +from tests.screens.exports.instruments.import_files.steps import choose_import, export_kept, segments from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.sequencer import forgive_the_hover_race, open_voice, open_voice_menu -from tests.suite.screens.views.history import HistoryLine, HistorySegment -from tests.suite.screens.world import ( - ARRANGED_PROJECT, - BASS_VOICE, - LINE, - PAD, - RELEASING_INSTRUMENT, - releasing_instrument, -) - -IMPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.import_instrument" +from tests.suite.screens.steps.exports import leaving_asks_nothing +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.views.history import HistorySegment +from tests.suite.screens.worlds.songs import PAD, RELEASING_INSTRUMENT, releasing_instrument + IMPORT_TITLE: Final[str] = "sequencer.voices.title.import_instrument_dialog" -EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" NO_PROJECT_TITLE: Final[str] = "global.dialog.title.no_project_open" NO_PROJECT_MESSAGE: Final[str] = "global.dialog.message.no_project_open" IMPORTED_TITLE: Final[str] = "sequencer.voices.title.instrument_imported" IMPORTED_TEMPLATE: Final[str] = "sequencer.voices.template.instrument_omissions" RELEASE_POINT: Final[str] = "sequencer.voices.label.omission_release_point" -NOT_FOUND: Final[str] = "sequencer.voices.message.instrument_not_found" -READ_FAILED: Final[str] = "Failed to read an instrument from" -NO_FILE: Final[str] = "No instrument file at" -FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and hold no instrument.\n" -KEPT: Final[str] = "Kept" -FOLDER: Final[str] = "kept" -VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 SETTLING_FRAMES: Final[int] = 20 -HALF: Final[int] = 2 - - -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - -def kept_folder() -> Path: - path = Path.cwd() / FOLDER - path.mkdir(parents=True, exist_ok=True) - return path - - -def choose_import(screen: Screen) -> None: - """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" - screen.tabs.bring_to_front(Tab.SEQUENCER) - screen.sequencer.voices.right_click_below_the_rows() - screen.expect(screen.context_menu.is_shown, bool, description="the list's menu") - screen.context_menu.choose(screen.words(IMPORT_INSTRUMENT)) - - -def export_kept(screen: Screen) -> Path: - """Exports the hand-written voice from its menu to the kept folder under the name :data:`KEPT`.""" - path = kept_folder() / f"{KEPT}{EXT_FILE_INSTRUMENT}" - notice = screen.exports.file_notice - screen.answer_next_dialog(DialogKind.SAVE, path) - open_voice_menu(screen, PAD) - screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) - screen.expect(notice.is_shown, bool, description="the notice of the export") - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice gone") - return path - - -def segments(lines: Tuple[HistoryLine, ...]) -> List[Tuple[HistorySegment, ...]]: - return [tuple(line.segments) for line in lines] - - -def leaving_asks_nothing(screen: Screen) -> None: - """Exits a project left as it was opened, which leaves at once.""" - forgive_the_hover_race(screen) - screen.press_shortcut(ShortcutId.EXIT) - - assert screen.wait_for_exit() class TestImportWithNoProjectOpen: - """Import instrument... with no project open says so and asks for no file; with a project open, it asks for one.""" + """Import instrument... with no project open says so and asks for no file; with a project open, it asks + for one. + + The project is closed, Import instrument... is chosen on the list's menu, and a notice says that no + project is open while the file dialog stays closed. A new project is created, and the same choice + now asks for a file and shows no notice. + """ def test_it_says_so_and_asks_for_no_file(self, screen: Screen) -> None: + """The notice appears with no project; with a new project the open dialog appears and the list stays + empty. + """ voices = screen.sequencer.voices notice = voices.no_project_notice asked: List[int] = [] @@ -139,11 +85,17 @@ def with_a_new_project_it_asks_for_a_file(screen: Screen) -> None: class TestAnExportedVoiceComesBack: - """An instrument exported and imported again: the dialog opens where it was written, Cancel changes nothing, and - the import adds one voice playing the same envelopes, with no notice, which one undo takes away. + """An instrument exported and imported again: the dialog opens where it was written, Cancel keeps + everything as it was, and the import adds one voice playing the same envelopes, with no notice, + which one undo takes away. + + The hand-written voice is exported from its menu. Cancel on the open dialog leaves the voices and + the history as they were. Choosing the exported file adds the voice and one history line. The new + voice shows the envelopes of the original, and one undo removes it. """ def test_the_round_trip(self, screen: Screen) -> None: + """The imported voice joins the list quietly, plays the same envelopes and goes with one undo.""" voices = screen.sequencer.voices history = screen.sequencer.history instruments = screen.reconstructions.instruments @@ -217,9 +169,14 @@ def one_undo_takes_it_away(screen: Screen) -> None: class TestAFileStatingMore: - """A FamiTracker instrument stating a release point imports, and a notice names the release point.""" + """A FamiTracker instrument stating a release point imports, and a notice names the release point. + + The file is chosen on the import dialog. The voice joins the list and the notice names the + instrument and the release point. Undo removes the voice. + """ def test_the_notice_names_what_it_left(self, screen: Screen) -> None: + """The imported voice is listed and the notice names the release point that the import set aside.""" voices = screen.sequencer.voices notice = voices.imported_notice @@ -242,74 +199,3 @@ def undo_and_leave(screen: Screen) -> None: screen.expect(voices.names, VOICES.__eq__, description="the import undone") screen.scenario(import_it, undo_and_leave, leaving_asks_nothing).run() - - -@dataclass(frozen=True) -class BrokenFile: - """A file the import meets broken: how it is laid in the home, and what the application logs on meeting it.""" - - name: str - lay: Callable[[Path, Path], None] - logged: str - not_found: bool - - -def foreign(destination: Path, _exported: Path) -> None: - destination.write_bytes(FOREIGN_BYTES) - - -def truncated(destination: Path, exported: Path) -> None: - content = exported.read_bytes() - destination.write_bytes(content[: len(content) // HALF]) - - -def missing(_destination: Path, _exported: Path) -> None: - """Lays nothing: the file is gone by the time the import reads it.""" - - -BROKEN_FILES: Final[Tuple[BrokenFile, ...]] = ( - BrokenFile(name="Foreign", lay=foreign, logged=READ_FAILED, not_found=False), - BrokenFile(name="Truncated", lay=truncated, logged=READ_FAILED, not_found=False), - BrokenFile(name="Missing", lay=missing, logged=NO_FILE, not_found=True), -) - - -class TestABrokenInstrumentFile: - """A file that is no instrument, a truncated one and a missing one each give a message naming the trouble, and - leave the voices and the history as they stood. - """ - - def test_each_is_reported_and_changes_nothing(self, screen: Screen) -> None: - voices = screen.sequencer.voices - history = screen.sequencer.history - exported: List[Path] = [] - before: List[List[Tuple[HistorySegment, ...]]] = [] - - def export_a_whole_file(screen: Screen) -> None: - exported.append(export_kept(screen)) - before.append(segments(history.lines())) - - def importing(broken: BrokenFile) -> None: - path = kept_folder() / f"{broken.name}{EXT_FILE_INSTRUMENT}" - broken.lay(path, exported[0]) - notice = screen.file_not_found_notice if broken.not_found else screen.error_notice - screen.answer_next_dialog(DialogKind.OPEN, path) - - choose_import(screen) - - screen.expect(notice.is_shown, bool, description=f"the message about {broken.name}") - screen.claim_error(broken.logged) - if broken.not_found: - assert screen.words(NOT_FOUND) in notice.words() - assert str(path) in notice.words() - - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the message gone") - assert voices.names() == VOICES - assert segments(history.lines()) == before[0] - - def import_each_broken_file(screen: Screen) -> None: - for broken in BROKEN_FILES: - importing(broken) - - screen.scenario(export_a_whole_file, import_each_broken_file, leaving_asks_nothing).run() diff --git a/tests/screens/exports/nsf_window/__init__.py b/tests/screens/exports/nsf_window/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/nsf_window/constants.py b/tests/screens/exports/nsf_window/constants.py new file mode 100644 index 000000000..edfcbb1ad --- /dev/null +++ b/tests/screens/exports/nsf_window/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +LEFT_OUT: Final[ChannelName] = ChannelName.NOISE +SETTLING_FRAMES: Final[int] = 20 diff --git a/tests/screens/exports/nsf_window/steps.py b/tests/screens/exports/nsf_window/steps.py new file mode 100644 index 000000000..1868b71a8 --- /dev/null +++ b/tests/screens/exports/nsf_window/steps.py @@ -0,0 +1,8 @@ +from pathlib import Path + + +def folder(name: str) -> Path: + """Creates and returns a folder of the home for the program, as a native dialog's answer would name it.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/tests/screens/exports/test_nsf_window.py b/tests/screens/exports/nsf_window/test_reaches_the_file.py similarity index 62% rename from tests/screens/exports/test_nsf_window.py rename to tests/screens/exports/nsf_window/test_reaches_the_file.py index 987c6fbc5..a6ac3ec37 100644 --- a/tests/screens/exports/test_nsf_window.py +++ b/tests/screens/exports/nsf_window/test_reaches_the_file.py @@ -1,4 +1,3 @@ -import operator from pathlib import Path from typing import Final, List @@ -13,20 +12,17 @@ from sampletones_player.export.backend import NSFBackend from sampletones_player.export.program import NSFProgram from sampletones_player.nsf.information import NSFInformation -from sampletones_player.specification.nsf import NSF_MAGIC from sampletones_shared.application import SAMPLETONES_COPYRIGHT from sampletones_shared.paths.extensions import EXT_FILE_NSF -from tests.suite.screens.application import Startup +from tests.screens.exports.nsf_window.constants import LEFT_OUT, SETTLING_FRAMES +from tests.screens.exports.nsf_window.steps import folder +from tests.suite.screens.application.startup import Startup from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.reconstructions import expect_open -from tests.suite.screens.world import LOOPING_PROJECT, PLAYABLE_RECONSTRUCTION +from tests.suite.screens.vocabulary.exports import NSF_EXPORTED, NSF_TYPE +from tests.suite.screens.worlds.songs import LOOPING_PROJECT NSF_TITLE: Final[str] = "global.dialog.title.export_nsf_project" -NSF_TYPE: Final[str] = "global.dialog.filter.nsf" -NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" -INSTRUMENTS_EXPORTED: Final[str] = "reconstructions.instruments.message.export_instruments_success" -NO_CHANNEL: Final[str] = "settings.nsf.message.no_channel" FROM_THE_START: Final[str] = "settings.nsf.label.repeat_from_start" FROM_A_FRAME: Final[str] = "settings.nsf.label.repeat_from_frame" HELD_SOUNDS: Final[str] = "settings.nsf.label.scheme_runs" @@ -34,21 +30,11 @@ TYPED_TITLE: Final[str] = "Rainy Day" SECOND_FRAME: Final[str] = "01" LOOP_FRAME: Final[int] = 1 -LEFT_OUT: Final[ChannelName] = ChannelName.NOISE -SILENT_IN_THE_RECONSTRUCTION: Final[ChannelName] = ChannelName.PULSE2 -SETTLING_FRAMES: Final[int] = 20 - - -def folder(name: str) -> Path: - """A folder of the home for the program, standing on the disk as a native dialog's answer does.""" - path = Path.cwd() / "exports" / name - path.mkdir(parents=True, exist_ok=True) - return path def write_chosen_program(destination: Path) -> None: - """Writes the program the window is set to in the scenario: noise left out, a repeat from the second frame, held sounds - stored once, and the typed title, the rest as the project states it. + """Writes the program the window is set to in the scenario: noise left out, a repeat from the second frame, + held sounds stored once and the typed title, the rest as the project states it. """ project = ProjectContainer.load(LOOPING_PROJECT) program = NSFProgram( @@ -74,7 +60,12 @@ def write_stated_program(destination: Path) -> None: class TestTheWindowReachesTheFile: - """Each choice made in the NSF window reaches the program it writes; with no channel ticked, Export waits.""" + """Each choice made in the NSF window reaches the program it writes; with every channel unticked, Export waits. + + The window opens on the project's own setup. The scenario unticks every channel, ticks all but noise, sets + the repeat, the level and the title, picks the file and presses Export. The file equals the program built from + those choices and differs from the program the project states. + """ @pytest.fixture def startup(self) -> Startup: @@ -163,63 +154,3 @@ def the_program_written_is_the_one_set_up(screen: Screen) -> None: choose_the_file, the_program_written_is_the_one_set_up, ).run() - - -class TestAReconstructionsSilentChannels: - """A reconstruction's program offers the channels it sounds alone: a silent channel's box is greyed and a press on - it ticks nothing. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_a_silent_channel_stays_out(self, screen: Screen) -> None: - window = screen.exports.nsf - destination = folder("reconstruction") / f"{PLAYABLE_RECONSTRUCTION.stem}{EXT_FILE_NSF}" - sounding = [channel for channel in ChannelName.items() if channel != SILENT_IN_THE_RECONSTRUCTION] - - def open_the_window(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - - screen.exports.export_reconstruction(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF) - - screen.expect(window.is_shown, bool, description="the NSF window") - assert not window.offers(SILENT_IN_THE_RECONSTRUCTION) - assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) - assert all(window.offers(channel) and window.is_ticked(channel) for channel in sounding) - - def a_press_on_the_silent_channel_ticks_nothing(screen: Screen) -> None: - window.press_on(SILENT_IN_THE_RECONSTRUCTION) - - screen.frames(SETTLING_FRAMES) - assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) - - def a_press_on_a_sounding_channel_lets_it_go_and_back(screen: Screen) -> None: - window.tick(LEFT_OUT) - screen.expect(lambda: window.is_ticked(LEFT_OUT), operator.not_, description="the noise let go") - - window.tick(LEFT_OUT) - - screen.expect(lambda: window.is_ticked(LEFT_OUT), bool, description="the noise ticked again") - assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) - - def the_program_is_written(screen: Screen) -> None: - notice = screen.exports.file_notice - screen.answer_next_dialog(DialogKind.SAVE, destination) - window.browse() - screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file") - - window.export() - - screen.expect(notice.is_shown, bool, description="the program written") - assert screen.words(INSTRUMENTS_EXPORTED) in notice.words() - assert destination.read_bytes().startswith(NSF_MAGIC) - notice.dismiss() - - screen.scenario( - open_the_window, - a_press_on_the_silent_channel_ticks_nothing, - a_press_on_a_sounding_channel_lets_it_go_and_back, - the_program_is_written, - ).run() diff --git a/tests/screens/exports/nsf_window/test_silent_channels.py b/tests/screens/exports/nsf_window/test_silent_channels.py new file mode 100644 index 000000000..fc7be1f5e --- /dev/null +++ b/tests/screens/exports/nsf_window/test_silent_channels.py @@ -0,0 +1,82 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.screens.exports.nsf_window.constants import LEFT_OUT, SETTLING_FRAMES +from tests.screens.exports.nsf_window.steps import folder +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +INSTRUMENTS_EXPORTED: Final[str] = "reconstructions.instruments.message.export_instruments_success" +SILENT_IN_THE_RECONSTRUCTION: Final[ChannelName] = ChannelName.PULSE2 + + +class TestAReconstructionsSilentChannels: + """The NSF window of a reconstruction offers only the channels it sounds; a press on a silent channel's greyed + box leaves it unticked. + + The scenario opens the window, presses the silent channel, unticks and ticks a sounding one, and writes the + program, which starts with the NSF signature. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_silent_channel_stays_out(self, screen: Screen) -> None: + window = screen.exports.nsf + destination = folder("reconstruction") / f"{PLAYABLE_RECONSTRUCTION.stem}{EXT_FILE_NSF}" + sounding = [channel for channel in ChannelName.items() if channel != SILENT_IN_THE_RECONSTRUCTION] + + def open_the_window(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.exports.export_reconstruction(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert not window.offers(SILENT_IN_THE_RECONSTRUCTION) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + assert all(window.offers(channel) and window.is_ticked(channel) for channel in sounding) + + def a_press_on_the_silent_channel_ticks_nothing(screen: Screen) -> None: + window.press_on(SILENT_IN_THE_RECONSTRUCTION) + + screen.frames(SETTLING_FRAMES) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def a_press_on_a_sounding_channel_lets_it_go_and_back(screen: Screen) -> None: + window.tick(LEFT_OUT) + screen.expect(lambda: window.is_ticked(LEFT_OUT), operator.not_, description="the noise let go") + + window.tick(LEFT_OUT) + + screen.expect(lambda: window.is_ticked(LEFT_OUT), bool, description="the noise ticked again") + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def the_program_is_written(screen: Screen) -> None: + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file") + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(INSTRUMENTS_EXPORTED) in notice.words() + assert destination.read_bytes().startswith(NSF_MAGIC) + notice.dismiss() + + screen.scenario( + open_the_window, + a_press_on_the_silent_channel_ticks_nothing, + a_press_on_a_sounding_channel_lets_it_go_and_back, + the_program_is_written, + ).run() diff --git a/tests/screens/exports/progress/__init__.py b/tests/screens/exports/progress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/progress/cases.py b/tests/screens/exports/progress/cases.py new file mode 100644 index 000000000..52279bde2 --- /dev/null +++ b/tests/screens/exports/progress/cases.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, + EXT_FILE_NSF, +) +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice_menu +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, PAD + + +def click_cancel(screen: Screen) -> None: + """Clicks Cancel in the export window.""" + screen.exports.progress.cancel() + + +def press_escape(screen: Screen) -> None: + """Presses Escape to cancel the export.""" + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + +@dataclass(frozen=True) +class ExportDoor: + """One door an export starts from: what it starts, what the window lists first, and how the user cancels it. + + Attributes: + name: The folder the door writes into, named after the door. + start: Starts the export into the folder it is given, answering the dialogs it opens. + first_stage: The stage the run reports first, which the window lists. + cancel: The user's gesture that cancels the run. + project: Whether the export writes a project, whose outcome appears in the project notice. + """ + + name: str + start: Callable[[Screen, Path], None] + first_stage: ExportStage + cancel: Callable[[Screen], None] + project: bool + + +def from_the_file_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + """Builds the start of a project export chosen from the File menu, saving as the arranged project.""" + + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{ARRANGED_PROJECT.stem}{extension}") + screen.exports.export_project(item) + + return start + + +def from_the_reconstruction_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + """Builds the start of an export chosen from the Reconstruction menu, saving as the playable reconstruction.""" + + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PLAYABLE_RECONSTRUCTION.stem}{extension}") + screen.exports.export_reconstruction(item) + + return start + + +def through_the_nsf_window(menu_start: Callable[[Screen], None], stem: str) -> Callable[[Screen, Path], None]: + """Builds the start of an NSF export: opens the window with ``menu_start``, picks the file named ``stem`` + and presses Export. + """ + + def start(screen: Screen, into: Path) -> None: + window = screen.exports.nsf + menu_start(screen) + screen.expect(window.is_shown, bool, description="the NSF window") + screen.answer_next_dialog(DialogKind.SAVE, into / f"{stem}{EXT_FILE_NSF}") + window.browse() + screen.expect( + lambda: screen.dialog_requests()[-1].answer == into / f"{stem}{EXT_FILE_NSF}", + bool, + description="the file chosen", + ) + window.export() + + return start + + +def from_the_voice_menu(screen: Screen, into: Path) -> None: + """Starts the export of one instrument from its right-click menu, saving as the instrument's name.""" + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PAD}{EXT_FILE_INSTRUMENT}") + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +PROJECT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="module", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, EXT_FILE_MODULE), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=True, + ), + ExportDoor( + name="bitphase", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_BITPHASE, EXT_FILE_BITPHASE), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=True, + ), + ExportDoor( + name="program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF), + ARRANGED_PROJECT.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=True, + ), +) + +INSTRUMENT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="instruments", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + EXT_FILE_INSTRUMENT, + ), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="presets", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, + EXT_FILE_JSON, + ), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=False, + ), + ExportDoor( + name="sample program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_reconstruction( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF + ), + PLAYABLE_RECONSTRUCTION.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="instrument", + start=from_the_voice_menu, + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), +) diff --git a/tests/screens/exports/progress/constants.py b/tests/screens/exports/progress/constants.py new file mode 100644 index 000000000..bbd376699 --- /dev/null +++ b/tests/screens/exports/progress/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +FAILED_EXPORT: Final[str] = "Failed to export to" +MODULE_EXPORTED: Final[str] = "global.dialog.message.project_exported_successfully" +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 diff --git a/tests/screens/exports/progress/steps.py b/tests/screens/exports/progress/steps.py new file mode 100644 index 000000000..56a9041c1 --- /dev/null +++ b/tests/screens/exports/progress/steps.py @@ -0,0 +1,23 @@ +import operator +from pathlib import Path + +from tests.suite.screens.screen import Screen + + +def folder(name: str) -> Path: + """Creates and returns a folder of the home for one door's files, as a native dialog's answer would name it.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def written_project(screen: Screen, exported_key: str, destination: Path) -> str: + """Waits for the notice of the project export ``exported_key`` words, dismisses it, and returns what it said.""" + notice = screen.exports.project_notice + screen.expect(notice.is_shown, bool, description=f"{destination.name} written") + words = notice.words() + assert screen.words(exported_key) in words + assert destination.is_file() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return words diff --git a/tests/screens/exports/progress/test_cancel.py b/tests/screens/exports/progress/test_cancel.py new file mode 100644 index 000000000..3f3f918a4 --- /dev/null +++ b/tests/screens/exports/progress/test_cancel.py @@ -0,0 +1,89 @@ +import operator +from typing import Dict, Final + +import pytest + +from sampletones_core.exports.stage import ExportStage +from tests.screens.exports.progress.cases import INSTRUMENT_DOORS, PROJECT_DOORS, ExportDoor +from tests.screens.exports.progress.steps import folder +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.export import FIRST_REPORT, ExportHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.notices import Notice +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +STAGE_WORDS: Final[Dict[ExportStage, str]] = { + ExportStage.WALKING: "settings.export.label.stage_walking", + ExportStage.COMPRESSING: "settings.export.label.stage_compressing", + ExportStage.WRITING: "settings.export.label.stage_writing", +} + +SETTLING_FRAMES: Final[int] = 20 + + +def outcome_notice(screen: Screen, door: ExportDoor) -> Notice: + """The notice a door's export ends with: the project notice for a project, the file notice otherwise.""" + return screen.exports.project_notice if door.project else screen.exports.file_notice + + +def canceled_and_then_written(screen: Screen, hold: ExportHold, door: ExportDoor) -> None: + """Starts ``door``'s export under the hold, cancels it, and starts it again unheld, which writes. + + After the cancel the window is gone, the folder is empty and no notice appears. + """ + progress = screen.exports.progress + notice = outcome_notice(screen, door) + into = folder(door.name) + hold.hold_at(FIRST_REPORT) + + door.start(screen, into) + + screen.expect(progress.is_shown, bool, description=f"the {door.name} export under way") + screen.expect(hold.waiting, (1).__eq__, description=f"the {door.name} export held") + assert progress.stages() == (screen.words(STAGE_WORDS[door.first_stage]),) + + door.cancel(screen) + + screen.expect(progress.is_shown, operator.not_, description=f"the {door.name} export canceled") + screen.frames(SETTLING_FRAMES) + assert hold.waiting() == 0 + assert not notice.is_shown() + assert list(into.iterdir()) == [] + + hold.release() + door.start(screen, into) + + screen.expect(notice.is_shown, bool, description=f"the {door.name} export written") + assert not progress.is_shown() + assert list(into.iterdir()) != [] + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +class TestEveryExportAnswersCancel: + """Every export shows its window while it runs, and a cancel stops it with the folder left empty; run to the end, it writes. + + The user cancels by clicking Cancel on some doors and pressing Escape on others. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + def test_the_project_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_project_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in PROJECT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_project_export).run() + + def test_the_instrument_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_instrument_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in INSTRUMENT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_instrument_export).run() diff --git a/tests/screens/exports/progress/test_failure_under_window.py b/tests/screens/exports/progress/test_failure_under_window.py new file mode 100644 index 000000000..d7acbb67e --- /dev/null +++ b/tests/screens/exports/progress/test_failure_under_window.py @@ -0,0 +1,69 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_shared.paths.extensions import EXT_FILE_MODULE +from tests.screens.exports.progress.constants import FAILED_EXPORT, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.holds.export import ExportHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +MODULE_FAILED: Final[str] = "global.dialog.message.project_export_failed" +READ_ONLY: Final[int] = 0o555 +WRITABLE: Final[int] = 0o755 + + +class TestAnExportFailingUnderItsWindow: + """An export failing while its window stands closes the window and shows one error saying what failed, with the + folder left empty; once the folder takes files again, the same export writes. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_the_error_follows_the_window(self, screen: Screen, export_hold: ExportHold) -> None: + progress = screen.exports.progress + into = folder("locked") + destination = into / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_the_module_export(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + screen.expect(progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def the_folder_refuses_the_file(screen: Screen) -> None: + into.chmod(READ_ONLY) + try: + export_hold.release() + + screen.expect(screen.error_notice.is_shown, bool, description="the export failed") + finally: + into.chmod(WRITABLE) + + screen.claim_error(FAILED_EXPORT) + assert not progress.is_shown() + standing = screen.error_notice.prompt.window() + assert standing is not None + assert [window.alias for window in screen.shown_windows()] == [standing.alias] + assert screen.words(MODULE_FAILED) in screen.error_notice.words() + assert list(into.iterdir()) == [] + screen.error_notice.dismiss() + screen.expect(screen.error_notice.is_shown, operator.not_, description="the error gone") + + def the_same_export_then_writes(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(start_the_module_export, the_folder_refuses_the_file, the_same_export_then_writes).run() diff --git a/tests/screens/exports/progress/test_refusals.py b/tests/screens/exports/progress/test_refusals.py new file mode 100644 index 000000000..e11ad4bf7 --- /dev/null +++ b/tests/screens/exports/progress/test_refusals.py @@ -0,0 +1,126 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_MODULE, EXT_FILE_NSF +from tests.screens.exports.progress.constants import FAILED_EXPORT, INSTRUMENT_CHANNEL, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice_menu +from tests.suite.screens.vocabulary.exports import EXPORT_INSTRUMENT, NSF_EXPORTED +from tests.suite.screens.worlds.songs import LINE, OVERLONG_PROJECT, TWO_TUNINGS_PROJECT + +NSF_FAILED: Final[str] = "global.dialog.message.nsf_project_export_failed" +BITPHASE_FAILED: Final[str] = "global.dialog.message.bitphase_project_export_failed" +STORED_TICK_BY_TICK: Final[str] = "settings.nsf.label.scheme_none" +EVERY_REPEAT_ONCE: Final[str] = "settings.nsf.label.scheme_search" + + +def refused(screen: Screen, failed_key: str, destination: Path) -> None: + """Waits for the error naming the export ``failed_key`` words, and checks that ``destination`` is still absent.""" + notice = screen.error_notice + screen.expect(notice.is_shown, bool, description="the export refused") + screen.claim_error(FAILED_EXPORT) + assert screen.words(failed_key) in notice.words() + assert not destination.exists() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the error gone") + + +def export_the_program(screen: Screen, level_key: str, destination: Path) -> None: + """Opens File > Export > NSF program..., picks the level ``level_key`` words and the file, and presses Export.""" + window = screen.exports.nsf + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + screen.expect(window.is_shown, bool, description="the NSF window") + window.choose_level(screen.words(level_key)) + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file chosen") + assert window.level() == screen.words(level_key) + + window.export() + + +class TestASongTooLargeForTheProgram: + """A song stored tick by tick that outgrows the room of an NSF program is refused with a message and the file + stays absent; stored with each repeat saved once, it is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=OVERLONG_PROJECT) + + def test_it_is_refused_and_then_fits(self, screen: Screen) -> None: + destination = folder("overlong") / f"{OVERLONG_PROJECT.stem}{EXT_FILE_NSF}" + + def stored_tick_by_tick_it_is_refused(screen: Screen) -> None: + export_the_program(screen, STORED_TICK_BY_TICK, destination) + + refused(screen, NSF_FAILED, destination) + + def saving_each_repeat_once_it_is_written(screen: Screen) -> None: + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + written_project(screen, NSF_EXPORTED, destination) + assert destination.read_bytes().startswith(NSF_MAGIC) + + screen.scenario(stored_tick_by_tick_it_is_refused, saving_each_repeat_once_it_is_written).run() + + +class TestTwoTunings: + """A project whose samples were converted at two tunings stops a song that sounds one tuning with a message and + writes no file; a module, which keeps each sample's own pitches, is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=TWO_TUNINGS_PROJECT) + + def test_a_song_sounding_one_tuning_stops(self, screen: Screen) -> None: + into = folder("tunings") + + def a_bitphase_project_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + refused(screen, BITPHASE_FAILED, destination) + + def a_program_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_NSF}" + + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + refused(screen, NSF_FAILED, destination) + + def a_module_is_written(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(a_bitphase_project_stops, a_program_stops, a_module_is_written).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: Export instrument... in a project of two tunings fails with no message", + ) + def test_an_instrument_export_stops_with_a_message(self, screen: Screen) -> None: + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in( + screen.words(EXPORT_INSTRUMENT), + screen.channel_words(INSTRUMENT_CHANNEL), + ) + + screen.expect(screen.error_notice.is_shown, bool, description="the export refused with a message") diff --git a/tests/screens/exports/progress/test_shortened_envelopes.py b/tests/screens/exports/progress/test_shortened_envelopes.py new file mode 100644 index 000000000..4b2e0e654 --- /dev/null +++ b/tests/screens/exports/progress/test_shortened_envelopes.py @@ -0,0 +1,135 @@ +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_MODULE +from tests.screens.exports.progress.constants import INSTRUMENT_CHANNEL, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.bitphase import parse_btp +from tests.suite.famitracker import parse_ftm +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.vocabulary.instruments import TOO_LONG +from tests.suite.screens.worlds.songs import ( + LONG_ENVELOPES_PROJECT, + LONG_ITEMS, + LONG_VOICE, + MIDDLING_ITEMS, + MIDDLING_VOICE, +) + +BITPHASE_EXPORTED: Final[str] = "global.dialog.message.bitphase_project_exported_successfully" +SHORTENED_PROJECT: Final[str] = "global.dialog.template.export_truncated" +KEPT_BY_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +KEPT_BY_BITPHASE: Final[str] = "reconstructions.instruments.template.kept_bitphase" +KEPT_BY_PRESET: Final[str] = "reconstructions.instruments.template.kept_bitphase_preset" +KEPT_SEPARATOR: Final[str] = "reconstructions.instruments.template.kept_separator" +ASIDE: Final[Point] = Point(x=0, y=0) + + +def written_for(names: List[str], voices: Tuple[str, ...]) -> int: + """Counts the instruments listed under ``names`` that were written for one of ``voices``.""" + return sum(1 for name in names if any(name == voice or name.startswith(f"{voice} (") for voice in voices)) + + +class TestShortenedEnvelopes: + """An envelope longer than a format stores shows, in its field's status, each export that cuts it and what each + keeps; a project export says how many of its instruments it shortened. + + The long voice runs past every format's limit, and the middling one past FamiTracker's alone. A format writes a + voice as one instrument or several, so the count in the notice is read against the instruments the file lists. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LONG_ENVELOPES_PROJECT) + + def test_the_status_and_the_notices_name_the_cuts(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + into = folder("shortened") + + def status_of(voice: str) -> str: + open_voice(screen, voice) + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + voice.__eq__, + description=f"{voice} open", + ) + screen.hand.move_to(ASIDE) + instruments.hover_field(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) + return screen.expect(screen.status, bool, description=f"the status of {voice}'s volume") + + def too_long(items: int, *kept: str) -> str: + return screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=items, + kept=screen.words(KEPT_SEPARATOR).join(kept), + ) + + def the_long_voice_names_every_format(screen: Screen) -> None: + status = status_of(LONG_VOICE) + + assert status == too_long( + LONG_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + screen.words(KEPT_BY_BITPHASE).format(limit=MAX_MACRO_LENGTH), + screen.words(KEPT_BY_PRESET).format(limit=MAX_MACRO_LENGTH), + ) + assert instruments.field_theme(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == TAG_GLOBAL_THEME_INPUT_WARNING + + def the_middling_voice_names_famitracker_alone(screen: Screen) -> None: + status = status_of(MIDDLING_VOICE) + + assert status == too_long( + MIDDLING_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + + def a_bitphase_project_shortens_one(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + words = written_project(screen, BITPHASE_EXPORTED, destination) + names = [instrument.name for instrument in parse_btp(destination.read_bytes(), []).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE,)), + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ITEMS, + ) + in words + ) + + def a_module_shortens_both(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + words = written_project(screen, MODULE_EXPORTED, destination) + names = [instrument.name for instrument in parse_ftm(destination.read_bytes()).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE, MIDDLING_VOICE)), + frames=MAX_SEQUENCE_ITEMS, + source_frames=LONG_ITEMS, + ) + in words + ) + + screen.scenario( + the_long_voice_names_every_format, + the_middling_voice_names_famitracker_alone, + a_bitphase_project_shortens_one, + a_module_shortens_both, + ).run() diff --git a/tests/screens/exports/test_progress.py b/tests/screens/exports/test_progress.py deleted file mode 100644 index cce6679ea..000000000 --- a/tests/screens/exports/test_progress.py +++ /dev/null @@ -1,544 +0,0 @@ -import operator -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Dict, Final, List, Tuple - -import pytest - -from sampletones_application.categories.elements.global_ import MenuElements -from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_WARNING -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exports.stage import ExportStage -from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH -from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS -from sampletones_player.specification.nsf import NSF_MAGIC -from sampletones_shared.paths.extensions import ( - EXT_FILE_BITPHASE, - EXT_FILE_INSTRUMENT, - EXT_FILE_JSON, - EXT_FILE_MODULE, - EXT_FILE_NSF, -) -from tests.suite.bitphase import parse_btp -from tests.suite.famitracker import parse_ftm -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE -from tests.suite.screens.holds import FIRST_REPORT, ExportHold -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.reconstructions import expect_open -from tests.suite.screens.steps.sequencer import open_voice, open_voice_menu -from tests.suite.screens.views.notices import Notice -from tests.suite.screens.world import ( - ARRANGED_PROJECT, - LINE, - LONG_ENVELOPES_PROJECT, - LONG_ITEMS, - LONG_VOICE, - MIDDLING_ITEMS, - MIDDLING_VOICE, - OVERLONG_PROJECT, - PAD, - PLAYABLE_RECONSTRUCTION, - TWO_TUNINGS_PROJECT, -) - -EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" -STAGE_WORDS: Final[Dict[ExportStage, str]] = { - ExportStage.WALKING: "settings.export.label.stage_walking", - ExportStage.COMPRESSING: "settings.export.label.stage_compressing", - ExportStage.WRITING: "settings.export.label.stage_writing", -} -SETTLING_FRAMES: Final[int] = 20 -FAILED_EXPORT: Final[str] = "Failed to export to" -NSF_FAILED: Final[str] = "global.dialog.message.nsf_project_export_failed" -NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" -BITPHASE_FAILED: Final[str] = "global.dialog.message.bitphase_project_export_failed" -BITPHASE_EXPORTED: Final[str] = "global.dialog.message.bitphase_project_exported_successfully" -MODULE_EXPORTED: Final[str] = "global.dialog.message.project_exported_successfully" -MODULE_FAILED: Final[str] = "global.dialog.message.project_export_failed" -READ_ONLY: Final[int] = 0o555 -WRITABLE: Final[int] = 0o755 -SHORTENED_PROJECT: Final[str] = "global.dialog.template.export_truncated" -STORED_TICK_BY_TICK: Final[str] = "settings.nsf.label.scheme_none" -EVERY_REPEAT_ONCE: Final[str] = "settings.nsf.label.scheme_search" -TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" -KEPT_BY_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" -KEPT_BY_BITPHASE: Final[str] = "reconstructions.instruments.template.kept_bitphase" -KEPT_BY_PRESET: Final[str] = "reconstructions.instruments.template.kept_bitphase_preset" -KEPT_SEPARATOR: Final[str] = "reconstructions.instruments.template.kept_separator" -INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 -ASIDE: Final[Point] = Point(x=0, y=0) - - -def folder(name: str) -> Path: - """A folder of the home for one door's files, standing on the disk as a native dialog's answer does.""" - path = Path.cwd() / "exports" / name - path.mkdir(parents=True, exist_ok=True) - return path - - -def click_cancel(screen: Screen) -> None: - screen.exports.progress.cancel() - - -def press_escape(screen: Screen) -> None: - screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) - - -@dataclass(frozen=True) -class ExportDoor: - """One way an export is started, what its window shows first, and how the reader cancels it. - - Attributes: - name: The folder the door writes into, named after the door. - start: Starts the export into the folder it is given, answering the dialogs it opens. - first_stage: The stage the run reports first, which the window lists. - cancel: The reader's way of canceling the run. - project: Whether the export writes the project, whose outcome stands in the project's notice. - """ - - name: str - start: Callable[[Screen, Path], None] - first_stage: ExportStage - cancel: Callable[[Screen], None] - project: bool - - -def from_the_file_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: - def start(screen: Screen, into: Path) -> None: - screen.answer_next_dialog(DialogKind.SAVE, into / f"{ARRANGED_PROJECT.stem}{extension}") - screen.exports.export_project(item) - - return start - - -def from_the_reconstruction_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: - def start(screen: Screen, into: Path) -> None: - screen.answer_next_dialog(DialogKind.SAVE, into / f"{PLAYABLE_RECONSTRUCTION.stem}{extension}") - screen.exports.export_reconstruction(item) - - return start - - -def through_the_nsf_window(menu_start: Callable[[Screen], None], stem: str) -> Callable[[Screen, Path], None]: - def start(screen: Screen, into: Path) -> None: - window = screen.exports.nsf - menu_start(screen) - screen.expect(window.is_shown, bool, description="the NSF window") - screen.answer_next_dialog(DialogKind.SAVE, into / f"{stem}{EXT_FILE_NSF}") - window.browse() - screen.expect( - lambda: screen.dialog_requests()[-1].answer == into / f"{stem}{EXT_FILE_NSF}", - bool, - description="the file chosen", - ) - window.export() - - return start - - -def from_the_voice_menu(screen: Screen, into: Path) -> None: - screen.answer_next_dialog(DialogKind.SAVE, into / f"{PAD}{EXT_FILE_INSTRUMENT}") - open_voice_menu(screen, PAD) - screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) - - -PROJECT_DOORS: Final[Tuple[ExportDoor, ...]] = ( - ExportDoor( - name="module", - start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, EXT_FILE_MODULE), - first_stage=ExportStage.WRITING, - cancel=click_cancel, - project=True, - ), - ExportDoor( - name="bitphase", - start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_BITPHASE, EXT_FILE_BITPHASE), - first_stage=ExportStage.WRITING, - cancel=press_escape, - project=True, - ), - ExportDoor( - name="program", - start=through_the_nsf_window( - lambda screen: screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF), - ARRANGED_PROJECT.stem, - ), - first_stage=ExportStage.WALKING, - cancel=click_cancel, - project=True, - ), -) - -INSTRUMENT_DOORS: Final[Tuple[ExportDoor, ...]] = ( - ExportDoor( - name="instruments", - start=from_the_reconstruction_menu( - MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, - EXT_FILE_INSTRUMENT, - ), - first_stage=ExportStage.WRITING, - cancel=click_cancel, - project=False, - ), - ExportDoor( - name="presets", - start=from_the_reconstruction_menu( - MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, - EXT_FILE_JSON, - ), - first_stage=ExportStage.WRITING, - cancel=press_escape, - project=False, - ), - ExportDoor( - name="sample program", - start=through_the_nsf_window( - lambda screen: screen.exports.export_reconstruction( - MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF - ), - PLAYABLE_RECONSTRUCTION.stem, - ), - first_stage=ExportStage.WALKING, - cancel=click_cancel, - project=False, - ), - ExportDoor( - name="instrument", - start=from_the_voice_menu, - first_stage=ExportStage.WRITING, - cancel=click_cancel, - project=False, - ), -) - - -def outcome_notice(screen: Screen, door: ExportDoor) -> Notice: - return screen.exports.project_notice if door.project else screen.exports.file_notice - - -def canceled_and_then_written(screen: Screen, hold: ExportHold, door: ExportDoor) -> None: - """Starts ``door``'s export under the hold, cancels it, and starts it again unheld, which writes.""" - progress = screen.exports.progress - notice = outcome_notice(screen, door) - into = folder(door.name) - hold.hold_at(FIRST_REPORT) - - door.start(screen, into) - - screen.expect(progress.is_shown, bool, description=f"the {door.name} export under way") - screen.expect(hold.waiting, (1).__eq__, description=f"the {door.name} export held") - assert progress.stages() == (screen.words(STAGE_WORDS[door.first_stage]),) - - door.cancel(screen) - - screen.expect(progress.is_shown, operator.not_, description=f"the {door.name} export canceled") - screen.frames(SETTLING_FRAMES) - assert hold.waiting() == 0 - assert not notice.is_shown() - assert list(into.iterdir()) == [] - - hold.release() - door.start(screen, into) - - screen.expect(notice.is_shown, bool, description=f"the {door.name} export written") - assert not progress.is_shown() - assert list(into.iterdir()) != [] - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice gone") - - -class TestEveryExportAnswersCancel: - """Every export shows its window while it runs and stops at a cancel with no file written; let run, it writes. - - Cancel is clicked on some doors and Escape pressed on others. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) - - def test_the_project_exports(self, screen: Screen, export_hold: ExportHold) -> None: - def each_project_export(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - for door in PROJECT_DOORS: - canceled_and_then_written(screen, export_hold, door) - - screen.scenario(each_project_export).run() - - def test_the_instrument_exports(self, screen: Screen, export_hold: ExportHold) -> None: - def each_instrument_export(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - for door in INSTRUMENT_DOORS: - canceled_and_then_written(screen, export_hold, door) - - screen.scenario(each_instrument_export).run() - - -def refused(screen: Screen, failed_key: str, destination: Path) -> None: - """Waits for the error naming the export ``failed_key`` words, and checks nothing was written to ``destination``.""" - notice = screen.error_notice - screen.expect(notice.is_shown, bool, description="the export refused") - screen.claim_error(FAILED_EXPORT) - assert screen.words(failed_key) in notice.words() - assert not destination.exists() - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the error gone") - - -def written_project(screen: Screen, exported_key: str, destination: Path) -> str: - """Waits for the notice of the project export ``exported_key`` words, dismisses it, and answers what it said.""" - notice = screen.exports.project_notice - screen.expect(notice.is_shown, bool, description=f"{destination.name} written") - words = notice.words() - assert screen.words(exported_key) in words - assert destination.is_file() - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice gone") - return words - - -def export_the_program(screen: Screen, level_key: str, destination: Path) -> None: - """Opens File โ–ธ Export โ–ธ NSF program..., picks the level ``level_key`` words and the file, and presses Export.""" - window = screen.exports.nsf - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) - screen.expect(window.is_shown, bool, description="the NSF window") - window.choose_level(screen.words(level_key)) - screen.answer_next_dialog(DialogKind.SAVE, destination) - window.browse() - screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file chosen") - assert window.level() == screen.words(level_key) - - window.export() - - -class TestASongTooLargeForTheProgram: - """A song stored tick by tick past the room an NSF program has is refused with a message, and nothing is written; - stored with each repeat saved once, it is written. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=OVERLONG_PROJECT) - - def test_it_is_refused_and_then_fits(self, screen: Screen) -> None: - destination = folder("overlong") / f"{OVERLONG_PROJECT.stem}{EXT_FILE_NSF}" - - def stored_tick_by_tick_it_is_refused(screen: Screen) -> None: - export_the_program(screen, STORED_TICK_BY_TICK, destination) - - refused(screen, NSF_FAILED, destination) - - def saving_each_repeat_once_it_is_written(screen: Screen) -> None: - export_the_program(screen, EVERY_REPEAT_ONCE, destination) - - written_project(screen, NSF_EXPORTED, destination) - assert destination.read_bytes().startswith(NSF_MAGIC) - - screen.scenario(stored_tick_by_tick_it_is_refused, saving_each_repeat_once_it_is_written).run() - - -class TestTwoTunings: - """A project whose samples were converted at two tunings: a song sounding one tuning stops with a message and - writes nothing, and a module, which keeps each sample's own pitches, is written. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=TWO_TUNINGS_PROJECT) - - def test_a_song_sounding_one_tuning_stops(self, screen: Screen) -> None: - into = folder("tunings") - - def a_bitphase_project_stops(screen: Screen) -> None: - destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_BITPHASE}" - screen.answer_next_dialog(DialogKind.SAVE, destination) - - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) - - refused(screen, BITPHASE_FAILED, destination) - - def a_program_stops(screen: Screen) -> None: - destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_NSF}" - - export_the_program(screen, EVERY_REPEAT_ONCE, destination) - - refused(screen, NSF_FAILED, destination) - - def a_module_is_written(screen: Screen) -> None: - destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_MODULE}" - screen.answer_next_dialog(DialogKind.SAVE, destination) - - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) - - written_project(screen, MODULE_EXPORTED, destination) - - screen.scenario(a_bitphase_project_stops, a_program_stops, a_module_is_written).run() - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: Export instrument... in a project of two tunings fails with no message", - ) - def test_an_instrument_export_stops_with_a_message(self, screen: Screen) -> None: - open_voice_menu(screen, LINE) - - screen.context_menu.choose_in( - screen.words(EXPORT_INSTRUMENT), - screen.channel_words(INSTRUMENT_CHANNEL), - ) - - screen.expect(screen.error_notice.is_shown, bool, description="the export refused with a message") - - -def written_for(names: List[str], voices: Tuple[str, ...]) -> int: - """How many of the instruments a file lists under ``names`` were written for one of ``voices``.""" - return sum(1 for name in names if any(name == voice or name.startswith(f"{voice} (") for voice in voices)) - - -class TestShortenedEnvelopes: - """An envelope longer than a format stores: its field's status names each export that cuts it and what each keeps, - and a project export says how many of the instruments it wrote it shortened. - - The long voice runs past every format's limit, and the middling one past FamiTracker's alone. A format writes a - voice as one instrument or several, so the count the notice gives is read against the instruments the file lists. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=LONG_ENVELOPES_PROJECT) - - def test_the_status_and_the_notices_name_the_cuts(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - into = folder("shortened") - - def status_of(voice: str) -> str: - open_voice(screen, voice) - screen.expect( - lambda: instruments.tab_label(INSTRUMENT_CHANNEL), - voice.__eq__, - description=f"{voice} open", - ) - screen.hand.move_to(ASIDE) - instruments.hover_field(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) - return screen.expect(screen.status, bool, description=f"the status of {voice}'s volume") - - def too_long(items: int, *kept: str) -> str: - return screen.words(TOO_LONG).format( - instrument_feature=FeatureKey.VOLUME.capitalized, - items=items, - kept=screen.words(KEPT_SEPARATOR).join(kept), - ) - - def the_long_voice_names_every_format(screen: Screen) -> None: - status = status_of(LONG_VOICE) - - assert status == too_long( - LONG_ITEMS, - screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), - screen.words(KEPT_BY_BITPHASE).format(limit=MAX_MACRO_LENGTH), - screen.words(KEPT_BY_PRESET).format(limit=MAX_MACRO_LENGTH), - ) - assert instruments.field_theme(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == TAG_GLOBAL_THEME_INPUT_WARNING - - def the_middling_voice_names_famitracker_alone(screen: Screen) -> None: - status = status_of(MIDDLING_VOICE) - - assert status == too_long( - MIDDLING_ITEMS, - screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), - ) - - def a_bitphase_project_shortens_one(screen: Screen) -> None: - destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_BITPHASE}" - screen.answer_next_dialog(DialogKind.SAVE, destination) - - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) - - words = written_project(screen, BITPHASE_EXPORTED, destination) - names = [instrument.name for instrument in parse_btp(destination.read_bytes(), []).instruments] - assert ( - screen.words(SHORTENED_PROJECT).format( - instruments=written_for(names, (LONG_VOICE,)), - frames=MAX_MACRO_LENGTH, - source_frames=LONG_ITEMS, - ) - in words - ) - - def a_module_shortens_both(screen: Screen) -> None: - destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_MODULE}" - screen.answer_next_dialog(DialogKind.SAVE, destination) - - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) - - words = written_project(screen, MODULE_EXPORTED, destination) - names = [instrument.name for instrument in parse_ftm(destination.read_bytes()).instruments] - assert ( - screen.words(SHORTENED_PROJECT).format( - instruments=written_for(names, (LONG_VOICE, MIDDLING_VOICE)), - frames=MAX_SEQUENCE_ITEMS, - source_frames=LONG_ITEMS, - ) - in words - ) - - screen.scenario( - the_long_voice_names_every_format, - the_middling_voice_names_famitracker_alone, - a_bitphase_project_shortens_one, - a_module_shortens_both, - ).run() - - -class TestAnExportFailingUnderItsWindow: - """An export failing while its window stands: the window goes, one error says what failed, and nothing is written; - once the folder takes files again, the same export writes. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - def test_the_error_follows_the_window(self, screen: Screen, export_hold: ExportHold) -> None: - progress = screen.exports.progress - into = folder("locked") - destination = into / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" - - def start_the_module_export(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.SAVE, destination) - - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) - - screen.expect(progress.is_shown, bool, description="the export under way") - screen.expect(export_hold.waiting, (1).__eq__, description="the export held") - - def the_folder_refuses_the_file(screen: Screen) -> None: - into.chmod(READ_ONLY) - try: - export_hold.release() - - screen.expect(screen.error_notice.is_shown, bool, description="the export failed") - finally: - into.chmod(WRITABLE) - - screen.claim_error(FAILED_EXPORT) - assert not progress.is_shown() - standing = screen.error_notice.prompt.window() - assert standing is not None - assert [window.alias for window in screen.shown_windows()] == [standing.alias] - assert screen.words(MODULE_FAILED) in screen.error_notice.words() - assert list(into.iterdir()) == [] - screen.error_notice.dismiss() - screen.expect(screen.error_notice.is_shown, operator.not_, description="the error gone") - - def the_same_export_then_writes(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.SAVE, destination) - - screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) - - written_project(screen, MODULE_EXPORTED, destination) - - screen.scenario(start_the_module_export, the_folder_refuses_the_file, the_same_export_then_writes).run() diff --git a/tests/screens/instructions/conftest.py b/tests/screens/instructions/conftest.py index 90d4f06a5..8a089322a 100644 --- a/tests/screens/instructions/conftest.py +++ b/tests/screens/instructions/conftest.py @@ -1,6 +1,7 @@ import pytest -from tests.suite.screens.world import World, converting_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import converting_world @pytest.fixture diff --git a/tests/screens/instructions/test_library.py b/tests/screens/instructions/test_library.py index 8605a3c7b..bd59d2563 100644 --- a/tests/screens/instructions/test_library.py +++ b/tests/screens/instructions/test_library.py @@ -3,10 +3,11 @@ from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS from sampletones_core.constants.enums import ChannelName, GeneratorName -from tests.suite.screens.dearpygui.items import Item, read_theme, read_theme_colors +from tests.suite.screens.dearpygui.items.colors import read_theme, read_theme_colors +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.screen import Screen from tests.suite.screens.steps.instructions import load_library -from tests.suite.screens.world import one_worker_config +from tests.suite.screens.worlds.home import one_worker_config GENERATOR_CHANNELS: Final[Dict[GeneratorName, ChannelName]] = { GeneratorName.PULSE: ChannelName.PULSE1, @@ -27,14 +28,20 @@ def read() -> Tuple[float, ...]: def channel_colors(screen: Screen, channel: ChannelName) -> Tuple[Tuple[float, ...], ...]: - """The colors the theme every view naming ``channel`` paints from holds.""" + """The colors of the theme that every view naming ``channel`` paints from.""" return screen.bridge.ask(lambda: read_theme_colors(CHANNEL_THEME_TAGS[channel])) class TestTheGeneratorsOfALibrary: - """A library lists a row per generator, each drawing its fragment's waveform and spectrum in its channel's color.""" + """A library lists a row per generator, each drawing its fragment's waveform and spectrum in its channel's + color. + + The library loads and shows a row per generator. Each row is clicked in turn, and its waveform draws one + line in a color of its channel's theme. The three generators end with three different colors. + """ def test_each_generator_draws_in_its_channels_color(self, screen: Screen) -> None: + """Each generator's row draws a waveform and a spectrum in its own channel's color.""" instructions = screen.instructions tree = instructions.library.tree waveform = instructions.waveform diff --git a/tests/screens/instructions/test_waveform.py b/tests/screens/instructions/test_waveform.py index b2fbbc8cc..097efa08a 100644 --- a/tests/screens/instructions/test_waveform.py +++ b/tests/screens/instructions/test_waveform.py @@ -3,17 +3,18 @@ from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY from sampletones_core.constants.enums import GeneratorName -from tests.suite.screens.dearpygui.items import read_label +from tests.suite.screens.dearpygui.items.texts import read_label from tests.suite.screens.screen import Screen from tests.suite.screens.steps.instructions import load_library from tests.suite.screens.vocabulary.playback import PAUSE -from tests.suite.screens.world import one_worker_config +from tests.suite.screens.worlds.home import one_worker_config LISTENING_FRAMES: Final[int] = 30 ZOOM_NOTCHES: Final[int] = 3 def show_the_pulse(screen: Screen) -> None: + """Loads the library, clicks the pulse row and waits until its waveform is drawn.""" instructions = screen.instructions tree = instructions.library.tree load_library(screen, one_worker_config()) @@ -27,10 +28,13 @@ def show_the_pulse(screen: Screen) -> None: class TestTheFragmentWaveform: """A click on the fragment's waveform sounds it, and a drag across a narrowed view sounds nothing. - A fragment lasts a few frames, so what plays is read on every frame the gesture spans. + A fragment lasts a few frames, so the Playback menu's entry is recorded on every frame the gesture spans. + The pulse is shown and zoomed in; the drag leaves the entry away from Pause, and the click brings Pause + up. """ def test_a_click_sounds_it_and_a_drag_does_not(self, screen: Screen) -> None: + """The Playback entry reads Pause during the click and stays away from it during the drag.""" waveform = screen.instructions.waveform playing = screen.words(PAUSE) diff --git a/tests/screens/interface/cards/__init__.py b/tests/screens/interface/cards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/test_cards.py b/tests/screens/interface/cards/test_every_card_folds.py similarity index 84% rename from tests/screens/interface/test_cards.py rename to tests/screens/interface/cards/test_every_card_folds.py index 47ea01ad0..b2cd0aa54 100644 --- a/tests/screens/interface/test_cards.py +++ b/tests/screens/interface/cards/test_every_card_folds.py @@ -8,13 +8,14 @@ from sampletones_application.tags.compose import TAG_SEPARATOR from sampletones_application.tags.general import SUF_COLLAPSE_STRIP from sampletones_application.tags.sequencer import TAG_SEQUENCER_HISTORY_PANEL -from tests.suite.screens.application import Startup +from tests.suite.screens.application.startup import Startup from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.items.reading import read_item from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import expect_open from tests.suite.screens.views.main import Card -from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT FILL_CARDS: Final[Tuple[str, ...]] = (TAG_SEQUENCER_HISTORY_PANEL,) TABS: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS) @@ -36,6 +37,7 @@ def cards_on(tab: Tab) -> List[str]: def box(screen: Screen, item: str) -> Rect: + """The rectangle ``item`` fills on the screen.""" rect = screen.bridge.ask(lambda: read_item(item).rect) assert rect is not None, f"{item} stands nowhere" return rect @@ -43,13 +45,16 @@ def box(screen: Screen, item: str) -> Rect: @pytest.fixture def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) class TestEveryCardFoldsOntoItsBar: - """Every card folds onto its bar: a card folding down keeps the bar and the card's padding around it, at its top - edge or, for a card filling its column, at its bottom; a card folding sideways keeps its rail; unfolded, each - stands as it stood. + """Every card folds onto its bar and unfolds back to where it stood. + + A card folding down keeps its bar and the card's padding around it, at its top edge or, for a card + filling its column, at its bottom. A card folding sideways keeps its rail. The scenario opens a + reconstruction, visits each tab, folds every card on it, unfolds it, and expects the same rectangle. """ def test_each_card_on_each_tab(self, screen: Screen) -> None: diff --git a/tests/screens/interface/conftest.py b/tests/screens/interface/conftest.py index 2e1a82a3a..14be9d912 100644 --- a/tests/screens/interface/conftest.py +++ b/tests/screens/interface/conftest.py @@ -1,6 +1,7 @@ import pytest -from tests.suite.screens.world import World, sequencer_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import sequencer_world @pytest.fixture diff --git a/tests/screens/interface/dialogs/__init__.py b/tests/screens/interface/dialogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/dialogs/cases.py b/tests/screens/interface/dialogs/cases.py new file mode 100644 index 000000000..4870e7ead --- /dev/null +++ b/tests/screens/interface/dialogs/cases.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_NSF_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) + + +@dataclass(frozen=True) +class MenuDialog: + """A dialog a menu entry opens. + + Attributes: + name: The dialog's name in messages. + group: The menu that holds the entry. + item: The entry that opens the dialog. + window: The tag its window stands under. + """ + + name: str + group: MenuElements + item: MenuElements + window: str + + +DIALOGS: Final[Tuple[MenuDialog, ...]] = ( + MenuDialog( + "Display settings", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, + TAG_SETTINGS_DISPLAY_WINDOW, + ), + MenuDialog( + "Keyboard shortcuts", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + ), + MenuDialog( + "Audio settings", + MenuElements.GROUP_PLAYBACK, + MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, + TAG_SETTINGS_AUDIO_WINDOW, + ), + MenuDialog( + "Project properties", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_PROJECT_PROPERTIES, + TAG_SETTINGS_PROPERTIES_WINDOW, + ), + MenuDialog( + "Render song", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_RENDER_SONG, + TAG_SETTINGS_RENDER_WINDOW, + ), + MenuDialog( + "NSF program", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_EXPORT_NSF, + TAG_SETTINGS_NSF_WINDOW, + ), + MenuDialog( + "About", + MenuElements.GROUP_HELP, + MenuElements.ITEM_HELP_ABOUT, + TAG_GLOBAL_DIALOG_ABOUT, + ), +) diff --git a/tests/screens/interface/test_dialog_sweep.py b/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py similarity index 55% rename from tests/screens/interface/test_dialog_sweep.py rename to tests/screens/interface/dialogs/test_every_dialog_opens_centered.py index d85a3d6e9..b5d5202aa 100644 --- a/tests/screens/interface/test_dialog_sweep.py +++ b/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py @@ -1,25 +1,17 @@ -from dataclasses import dataclass from functools import partial -from typing import Final, List, Optional, Tuple +from typing import Final, List, Optional import pytest -from sampletones_application.categories.elements.global_ import MenuElements -from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT -from sampletones_application.tags.settings import ( - TAG_SETTINGS_AUDIO_WINDOW, - TAG_SETTINGS_DISPLAY_WINDOW, - TAG_SETTINGS_KEYBINDINGS_WINDOW, - TAG_SETTINGS_NSF_WINDOW, - TAG_SETTINGS_PROPERTIES_WINDOW, - TAG_SETTINGS_RENDER_WINDOW, -) -from tests.suite.screens.application import Startup +from tests.screens.interface.dialogs.cases import DIALOGS, MenuDialog +from tests.suite.screens.application.startup import Startup from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import is_tag_within, read_viewport, read_windows +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import read_windows +from tests.suite.screens.dearpygui.items.viewport import read_viewport from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE from tests.suite.screens.screen import Screen -from tests.suite.screens.world import ARRANGED_PROJECT +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT STILL_FRAMES: Final[int] = 20 CENTER_TOLERANCE: Final[float] = 1.0 @@ -27,62 +19,6 @@ SETTLING_DRAWN_FRAMES: Final[int] = 2 -@dataclass(frozen=True) -class MenuDialog: - """A dialog a menu entry opens, and the tag its window stands under.""" - - name: str - group: MenuElements - item: MenuElements - window: str - - -DIALOGS: Final[Tuple[MenuDialog, ...]] = ( - MenuDialog( - "Display settings", - MenuElements.GROUP_VIEW, - MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, - TAG_SETTINGS_DISPLAY_WINDOW, - ), - MenuDialog( - "Keyboard shortcuts", - MenuElements.GROUP_VIEW, - MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, - TAG_SETTINGS_KEYBINDINGS_WINDOW, - ), - MenuDialog( - "Audio settings", - MenuElements.GROUP_PLAYBACK, - MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, - TAG_SETTINGS_AUDIO_WINDOW, - ), - MenuDialog( - "Project properties", - MenuElements.GROUP_FILE, - MenuElements.ITEM_FILE_PROJECT_PROPERTIES, - TAG_SETTINGS_PROPERTIES_WINDOW, - ), - MenuDialog( - "Render song", - MenuElements.GROUP_FILE, - MenuElements.ITEM_FILE_RENDER_SONG, - TAG_SETTINGS_RENDER_WINDOW, - ), - MenuDialog( - "NSF program", - MenuElements.GROUP_FILE, - MenuElements.ITEM_FILE_EXPORT_NSF, - TAG_SETTINGS_NSF_WINDOW, - ), - MenuDialog( - "About", - MenuElements.GROUP_HELP, - MenuElements.ITEM_HELP_ABOUT, - TAG_GLOBAL_DIALOG_ABOUT, - ), -) - - def dialog_box(window: str) -> Optional[Rect]: """The box of the shown window standing under ``window``, if one stands. Runs on the render thread.""" return next( @@ -92,6 +28,7 @@ def dialog_box(window: str) -> Optional[Rect]: def is_centered(box: Rect, viewport: Rect) -> bool: + """Whether the center of ``box`` lies within a pixel of the center of ``viewport``.""" return ( abs(box.x + box.width / HALF - viewport.width / HALF) <= CENTER_TOLERANCE and abs(box.y + box.height / HALF - viewport.height / HALF) <= CENTER_TOLERANCE @@ -99,20 +36,23 @@ def is_centered(box: Rect, viewport: Rect) -> bool: def is_inside(box: Rect, viewport: Rect) -> bool: + """Whether ``box`` lies wholly inside ``viewport``.""" return box.x >= 0 and box.y >= 0 and box.x + box.width <= viewport.width and box.y + box.height <= viewport.height @pytest.fixture def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" return Startup(reconstruction=None, project=ARRANGED_PROJECT) class TestEveryDialogOpensCentered: - """On its first open of the session, every dialog a menu opens stands centered in the window and inside it, in - one place on every frame from its third drawn frame on. + """On its first open of the session, every dialog a menu opens stands centered in the window and + inside it, in one place on every frame from its third drawn frame on. - A dialog whose content settles its height takes two frames to measure itself, as `dialogs.md` states, so the - rule holds from the frame after those. + A dialog whose content settles its height takes two frames to measure itself, as `dialogs.md` + states, so the rule holds from the frame after those. The scenario opens each dialog from its menu + while recording its box, then closes it with Escape. """ def test_each_dialog_on_its_first_open(self, screen: Screen) -> None: diff --git a/tests/screens/interface/display/__init__.py b/tests/screens/interface/display/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/display/constants.py b/tests/screens/interface/display/constants.py new file mode 100644 index 000000000..0055f2b9c --- /dev/null +++ b/tests/screens/interface/display/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SIZE_SEPARATOR: Final[str] = "x" diff --git a/tests/screens/interface/display/steps.py b/tests/screens/interface/display/steps.py new file mode 100644 index 000000000..d0836a19b --- /dev/null +++ b/tests/screens/interface/display/steps.py @@ -0,0 +1,46 @@ +import operator +from typing import Tuple + +from sampletones_shared.display import Resolution +from tests.screens.interface.display.constants import SIZE_SEPARATOR +from tests.suite.screens.dearpygui.items.viewport import read_viewport, read_viewport_decorated +from tests.suite.screens.screen import Screen + + +def size_named(label: str) -> Resolution: + """The window size a Resolution entry names, written width first.""" + width, height = label.split(SIZE_SEPARATOR) + return Resolution(width=int(width), height=int(height)) + + +def window_size(screen: Screen) -> Tuple[int, int]: + """The width and height of the window in pixels.""" + viewport = screen.bridge.ask(read_viewport) + return round(viewport.width), round(viewport.height) + + +def framed(screen: Screen) -> bool: + """Whether the window has its frame.""" + return screen.bridge.ask(read_viewport_decorated) + + +def open_display_settings(screen: Screen) -> None: + """Opens Display settings and waits for it to show.""" + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + +def another_size(screen: Screen) -> str: + """A size the Resolution list offers other than the one it names.""" + settings = screen.display_settings + current = settings.resolution() + return next(label for label in settings.resolutions() if label != current) + + +def kept(screen: Screen) -> None: + """Waits for the countdown a window change starts, and presses Keep.""" + settings = screen.display_settings + screen.expect(settings.countdown_shown, bool, description="the countdown") + settings.keep() + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") diff --git a/tests/screens/interface/display/test_countdown.py b/tests/screens/interface/display/test_countdown.py new file mode 100644 index 000000000..38cc004f3 --- /dev/null +++ b/tests/screens/interface/display/test_countdown.py @@ -0,0 +1,63 @@ +import operator +from typing import Final + +from tests.screens.interface.display.steps import framed, kept, open_display_settings +from tests.suite.screens.screen import Screen + +COUNTDOWN_LIMIT_SECONDS: Final[float] = 20.0 +SETTLING_FRAMES: Final[int] = 10 + + +class TestTheWindowModeCountdown: + """A window change counts down: Keep keeps it, while Revert and the count running out bring the window + back. Cancel then puts back the window the dialog opened with. + + The scenario switches to a frameless window and presses Revert, switches again and lets the + countdown run out, then switches once more, presses Keep, and cancels the dialog, expecting the + frame back. + """ + + def test_keep_revert_and_running_out(self, screen: Screen) -> None: + settings = screen.display_settings + + def revert_brings_the_frame_back(screen: Screen) -> None: + open_display_settings(screen) + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not framed(screen) + + settings.revert() + + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def running_out_brings_it_back(screen: Screen) -> None: + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + + screen.bridge.expect( + settings.countdown_shown, + operator.not_, + description="the countdown run out", + timeout=COUNTDOWN_LIMIT_SECONDS, + ) + + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def keep_keeps_it_until_cancel(screen: Screen) -> None: + settings.toggle_borderless() + + kept(screen) + + screen.frames(SETTLING_FRAMES) + assert not framed(screen) + assert settings.borderless() + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.expect(lambda: framed(screen), bool, description="the frame back") + + screen.scenario(revert_brings_the_frame_back, running_out_brings_it_back, keep_keeps_it_until_cancel).run() diff --git a/tests/screens/dialogs/test_display_settings.py b/tests/screens/interface/display/test_discard_prompt.py similarity index 89% rename from tests/screens/dialogs/test_display_settings.py rename to tests/screens/interface/display/test_discard_prompt.py index b75164f47..21b0cfbcc 100644 --- a/tests/screens/dialogs/test_display_settings.py +++ b/tests/screens/interface/display/test_discard_prompt.py @@ -6,7 +6,11 @@ class TestDiscardingADisplayChange: - """Cancelling Display settings with a change pending asks before the change is thrown away.""" + """Cancelling Display settings with a change pending asks before the change is thrown away. + + The scenario flips vertical sync and cancels. Keep editing returns to the dialog with the change + in place. Cancel and discard close it, and the dialog reopens with the original value. + """ def test_keep_editing_brings_the_dialog_back_and_discard_restores_it(self, screen: Screen) -> None: settings = screen.display_settings @@ -66,6 +70,7 @@ class TestTheKeyboardOnDisplaySettings: """The keys alone answer Display settings.""" def test_escape_closes_an_unchanged_dialog(self, screen: Screen) -> None: + """Escape closes a dialog that holds no change, with no question.""" settings = screen.display_settings settings.open() screen.expect(settings.is_shown, bool, description="the Display settings dialog") diff --git a/tests/screens/interface/display/test_discarded_change_not_written.py b/tests/screens/interface/display/test_discarded_change_not_written.py new file mode 100644 index 000000000..7764529dc --- /dev/null +++ b/tests/screens/interface/display/test_discarded_change_not_written.py @@ -0,0 +1,51 @@ +import operator +from typing import List + +from tests.screens.application.restart.steps import leave +from tests.suite.screens.screen import Screen +from tests.suite.screens.written import written_application_config + + +class TestADiscardedDisplayChange: + """A Display settings change thrown away stays out of the written settings, while one confirmed is + written. + + The scenario flips vertical sync and discards it, flips it again and confirms, leaves the + application, and expects the written settings to hold the confirmed value. + """ + + def test_leaving_writes_only_the_confirmed_change(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def discard_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def confirm_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + assert settings.vsync() == original[0] + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def leaving_writes_the_confirmed_one(screen: Screen) -> None: + leave(screen) + + assert written_application_config().display.vsync is not original[0] + + screen.scenario(discard_a_change, confirm_a_change, leaving_writes_the_confirmed_one).run() diff --git a/tests/screens/interface/test_fullscreen.py b/tests/screens/interface/display/test_fullscreen.py similarity index 87% rename from tests/screens/interface/test_fullscreen.py rename to tests/screens/interface/display/test_fullscreen.py index e25700be7..70ca25373 100644 --- a/tests/screens/interface/test_fullscreen.py +++ b/tests/screens/interface/display/test_fullscreen.py @@ -3,7 +3,7 @@ from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import read_viewport +from tests.suite.screens.dearpygui.items.viewport import read_viewport from tests.suite.screens.environment import SCREEN_SIZE from tests.suite.screens.screen import Screen from tests.suite.screens.written import written_state @@ -12,6 +12,7 @@ def window(screen: Screen) -> Rect: + """The rectangle the window fills on the screen.""" return screen.bridge.ask(read_viewport) @@ -21,8 +22,10 @@ def marked_fullscreen(screen: Screen) -> bool: class TestFullscreen: - """F11 fills the screen and F11 again gives the window back its size and place; the View menu's mark and what - leaving writes follow the window. + """F11 fills the screen and F11 again gives the window back its size and place. + + The View menu's mark follows the window, and leaving the application from the windowed state writes + a session that is not fullscreen. Two presses in a row end where they began. """ def test_f11_fills_and_restores(self, screen: Screen) -> None: diff --git a/tests/screens/interface/display/test_kept_size_at_close.py b/tests/screens/interface/display/test_kept_size_at_close.py new file mode 100644 index 000000000..6380dfb1f --- /dev/null +++ b/tests/screens/interface/display/test_kept_size_at_close.py @@ -0,0 +1,49 @@ +from typing import List, Tuple + +import pytest + +from tests.screens.interface.display.steps import another_size, kept, open_display_settings, size_named, window_size +from tests.suite.screens.screen import Screen +from tests.suite.screens.written import written_state + + +class TestClosingWithASizeKeptButNotConfirmed: + """Closing the window while Display settings holds a size kept on the countdown but left unconfirmed + writes the size the dialog opened with. + + The session keeps that size, as it does for every setting the dialog has yet to commit. The + scenario keeps another size, closes the window, and expects the opening size in the written + session. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: closing the window writes a size Display settings never confirmed", + ) + def test_leaving_writes_the_confirmed_size(self, screen: Screen) -> None: + settings = screen.display_settings + opened: List[Tuple[int, int]] = [] + + def keep_a_size_without_confirming_it(screen: Screen) -> None: + opened.append(window_size(screen)) + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + assert screen.wait_for_exit() + viewport = written_state().viewport + assert (viewport.width, viewport.height) == opened[0] + + screen.scenario(keep_a_size_without_confirming_it, close_the_window).run() diff --git a/tests/screens/interface/display/test_saved_mode.py b/tests/screens/interface/display/test_saved_mode.py new file mode 100644 index 000000000..761e30510 --- /dev/null +++ b/tests/screens/interface/display/test_saved_mode.py @@ -0,0 +1,114 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.display import DisplayConfig +from sampletones_application.config.session.state.window import ViewportState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.display import Resolution +from tests.screens.interface.display.steps import ( + another_size, + framed, + kept, + open_display_settings, + size_named, + window_size, +) +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World, screen_filling_state +from tests.suite.screens.written import written_application_config, written_state + +SEEDED_SIZE: Final[Resolution] = Resolution(width=1280, height=800) + + +def leave(screen: Screen) -> None: + """Leaves the application with the exit shortcut and waits for it to close.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestLeavingWritesTheWindowMode: + """A window size and a frameless window, kept on the countdown and confirmed with OK, are what leaving + writes. + """ + + def test_the_size_and_the_frame_are_written(self, screen: Screen) -> None: + settings = screen.display_settings + chosen: List[Resolution] = [] + + def pick_a_size_and_keep_it(screen: Screen) -> None: + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + chosen.append(size_named(label)) + screen.expect( + lambda: window_size(screen), + (chosen[0].width, chosen[0].height).__eq__, + description="the window at the size picked", + ) + + def go_frameless_and_keep_it(screen: Screen) -> None: + assert framed(screen) + + settings.toggle_borderless() + kept(screen) + + screen.expect(lambda: framed(screen), operator.not_, description="the window without its frame") + + def confirm_and_leave(screen: Screen) -> None: + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + leave(screen) + + viewport = written_state().viewport + assert (viewport.width, viewport.height) == (chosen[0].width, chosen[0].height) + assert written_application_config().display.borderless + + screen.scenario(pick_a_size_and_keep_it, go_frameless_and_keep_it, confirm_and_leave).run() + + +class TestAHomeHoldingAWindowModeShowsIt: + """A home whose session holds a window size and whose settings ask for a frameless window without + vertical sync opens the application that way, and Display settings shows it. + """ + + @pytest.fixture + def world(self) -> World: + """A home with a 1280 by 800 window at the screen's corner, frameless and without vertical sync.""" + state = screen_filling_state().model_copy( + update={"viewport": ViewportState(width=SEEDED_SIZE.width, height=SEEDED_SIZE.height, x=0, y=0)} + ) + return World( + state=state, + application_config=ApplicationConfig(display=DisplayConfig(borderless=True, vsync=False)), + config=None, + files=(), + ) + + def test_the_window_opens_as_the_session_left_it(self, screen: Screen) -> None: + """The window opens at the written size without its frame, and Display settings names the size, the + frameless mode and the vertical sync setting. + """ + settings = screen.display_settings + + def the_window_stands_as_written(screen: Screen) -> None: + assert window_size(screen) == (SEEDED_SIZE.width, SEEDED_SIZE.height) + assert not framed(screen) + + def display_settings_names_it(screen: Screen) -> None: + open_display_settings(screen) + + assert settings.resolution() == str(SEEDED_SIZE) + assert settings.borderless() + assert not settings.vsync() + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(the_window_stands_as_written, display_settings_names_it).run() diff --git a/tests/screens/interface/keybindings/__init__.py b/tests/screens/interface/keybindings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/test_keybindings.py b/tests/screens/interface/keybindings/test_rebinding_undo.py similarity index 87% rename from tests/screens/interface/test_keybindings.py rename to tests/screens/interface/keybindings/test_rebinding_undo.py index 31932e82e..5925d0527 100644 --- a/tests/screens/interface/test_keybindings.py +++ b/tests/screens/interface/keybindings/test_rebinding_undo.py @@ -9,16 +9,16 @@ from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from tests.suite.screens.application import Startup -from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.items.reading import read_item from tests.suite.screens.dearpygui.keys import IMGUI_ENTER, IMGUI_ESCAPE from tests.suite.screens.keyboard import press_combination from tests.suite.screens.screen import Screen from tests.suite.screens.steps.sequencer import forgive_the_hover_race -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD +from tests.suite.screens.vocabulary.settings import LISTENING +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD from tests.suite.screens.written import written_application_config -LISTENING: Final[str] = "settings.keybindings.message.capturing" NEW_UNDO: Final[KeyCombination] = KeyCombination.parse("Ctrl+Alt+U") RENAMED: Final[str] = "Renamed" SETTLING_FRAMES: Final[int] = 20 @@ -26,6 +26,7 @@ @pytest.fixture def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" return Startup(reconstruction=None, project=ARRANGED_PROJECT) @@ -46,8 +47,11 @@ def rename_the_line(screen: Screen) -> None: class TestRebindingUndo: - """Undo rebound: Escape cancels a capture, the new keys undo while the old ones do nothing, the menu prints the - new keys, keys another action holds ask before they are taken, and leaving writes the rebind. + """Undo rebound: the new keys undo and the old ones leave the document as it was, and the menu prints the new keys. + + Escape cancels a capture, keys another action holds ask before they are taken, and leaving writes + the rebind. The scenario rebinds Undo to Ctrl+Alt+U, renames a voice, tries the old keys and then + the new ones, tries to take the Redo keys and cancels, and leaves the application. """ def test_the_new_keys_undo(self, screen: Screen) -> None: diff --git a/tests/screens/interface/menus/__init__.py b/tests/screens/interface/menus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/menus/conftest.py b/tests/screens/interface/menus/conftest.py new file mode 100644 index 000000000..bdcbbf5fe --- /dev/null +++ b/tests/screens/interface/menus/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/menus/test_popup_width.py b/tests/screens/interface/menus/test_popup_width.py new file mode 100644 index 000000000..fc47df311 --- /dev/null +++ b/tests/screens/interface/menus/test_popup_width.py @@ -0,0 +1,71 @@ +from typing import Final, List, Tuple + +import numpy as np + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.palettes import Color, shipped_palettes, token_color +from tests.suite.screens.screen import Screen + +POPUP_TOKEN: Final[str] = "popup" +RGB: Final[int] = 3 +COLOR_TOLERANCE: Final[float] = 1.5 / 255.0 +LEFT_INSET: Final[int] = 4 +OPENING_FRAMES: Final[int] = 5 +READINGS: Final[int] = 8 +FRAMES_BETWEEN_READINGS: Final[int] = 3 + +MENUS: Final[Tuple[MenuElements, ...]] = ( + MenuElements.GROUP_FILE, + MenuElements.GROUP_EDIT, + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.GROUP_VOICE, + MenuElements.GROUP_PLAYBACK, + MenuElements.GROUP_VIEW, + MenuElements.GROUP_HELP, +) + + +def popup_right_edge(pixels: np.ndarray, header: Point, background: Color) -> int: + """The last column the popup below ``header`` paints in its background, on the rows it spans. + + The popup's rows are those its background reaches just inside its left edge, under the header. + """ + painted = np.all(np.abs(pixels[:, :, :RGB] - np.array(background[:RGB])) <= COLOR_TOLERANCE, axis=2) + column = header.x + LEFT_INSET + rows = np.nonzero(painted[:, column])[0] + rows = rows[rows > header.y] + assert rows.size, f"no popup stands under the header at {header}" + columns = np.nonzero(painted[rows, column:].any(axis=0))[0] + return int(columns.max()) + column + + +class TestMenuPopupsHoldTheirWidth: + """Every menu popup keeps its width on every frame it stands open. + + The scenario opens each menu, reads the popup's right edge on several frames, and expects one + value. + """ + + def test_each_popup_stands_still(self, screen: Screen) -> None: + background = token_color(shipped_palettes().get(DEFAULT_PALETTE_NAME), POPUP_TOKEN) + + def measuring(group: MenuElements) -> None: + screen.menu.open(group) + screen.frames(OPENING_FRAMES) + header = screen.menu.header(group) + edges: List[int] = [] + for _ in range(READINGS): + edges.append(popup_right_edge(screen.frame_pixels(), header, background)) + screen.frames(FRAMES_BETWEEN_READINGS) + + assert len(set(edges)) == 1, f"the {group} popup widened: {edges}" + screen.menu.close() + screen.frames(OPENING_FRAMES) + + def open_each_menu(screen: Screen) -> None: + for group in MENUS: + measuring(group) + + screen.scenario(open_each_menu).run() diff --git a/tests/screens/interface/test_menus.py b/tests/screens/interface/menus/test_printed_keys.py similarity index 58% rename from tests/screens/interface/test_menus.py rename to tests/screens/interface/menus/test_printed_keys.py index 09292664a..b7bf2d099 100644 --- a/tests/screens/interface/test_menus.py +++ b/tests/screens/interface/menus/test_printed_keys.py @@ -1,33 +1,9 @@ -from typing import Final, List, Tuple - -import numpy as np -import pytest +from typing import Final, Tuple from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME -from tests.suite.screens.application import Startup -from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.palettes import Color, shipped_palettes, token_color from tests.suite.screens.screen import Screen -from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION -POPUP_TOKEN: Final[str] = "popup" -RGB: Final[int] = 3 -COLOR_TOLERANCE: Final[float] = 1.5 / 255.0 -LEFT_INSET: Final[int] = 4 -OPENING_FRAMES: Final[int] = 5 -READINGS: Final[int] = 8 -FRAMES_BETWEEN_READINGS: Final[int] = 3 -MENUS: Final[Tuple[MenuElements, ...]] = ( - MenuElements.GROUP_FILE, - MenuElements.GROUP_EDIT, - MenuElements.GROUP_RECONSTRUCTION, - MenuElements.GROUP_VOICE, - MenuElements.GROUP_PLAYBACK, - MenuElements.GROUP_VIEW, - MenuElements.GROUP_HELP, -) PRINTED: Final[Tuple[Tuple[MenuElements, MenuElements, ShortcutId], ...]] = ( (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT, ShortcutId.NEW_PROJECT), (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT, ShortcutId.OPEN_PROJECT), @@ -80,51 +56,6 @@ ) -def popup_right_edge(pixels: np.ndarray, header: Point, background: Color) -> int: - """The last column the popup below ``header`` paints in its background, on the rows it spans. - - The popup's rows are those its background reaches just inside its left edge, under the header. - """ - painted = np.all(np.abs(pixels[:, :, :RGB] - np.array(background[:RGB])) <= COLOR_TOLERANCE, axis=2) - column = header.x + LEFT_INSET - rows = np.nonzero(painted[:, column])[0] - rows = rows[rows > header.y] - assert rows.size, f"no popup stands under the header at {header}" - columns = np.nonzero(painted[rows, column:].any(axis=0))[0] - return int(columns.max()) + column - - -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) - - -class TestMenuPopupsHoldTheirWidth: - """Every menu popup keeps its width on every frame it stands open.""" - - def test_each_popup_stands_still(self, screen: Screen) -> None: - background = token_color(shipped_palettes().get(DEFAULT_PALETTE_NAME), POPUP_TOKEN) - - def measuring(group: MenuElements) -> None: - screen.menu.open(group) - screen.frames(OPENING_FRAMES) - header = screen.menu.header(group) - edges: List[int] = [] - for _ in range(READINGS): - edges.append(popup_right_edge(screen.frame_pixels(), header, background)) - screen.frames(FRAMES_BETWEEN_READINGS) - - assert len(set(edges)) == 1, f"the {group} popup widened: {edges}" - screen.menu.close() - screen.frames(OPENING_FRAMES) - - def open_each_menu(screen: Screen) -> None: - for group in MENUS: - measuring(group) - - screen.scenario(open_each_menu).run() - - class TestPrintedKeysFollowTheScheme: """Each menu entry prints the keys the scheme in place gives the action it runs.""" diff --git a/tests/screens/interface/palettes/__init__.py b/tests/screens/interface/palettes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/palettes/cases.py b/tests/screens/interface/palettes/cases.py new file mode 100644 index 000000000..87368155c --- /dev/null +++ b/tests/screens/interface/palettes/cases.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import Dict + +from tests.suite.screens.boundaries.highlights import HighlightPlace +from tests.suite.screens.palettes import Color + + +@dataclass(frozen=True) +class Painted: + """Every color the interface holds at one moment. + + Attributes: + held: The colors DearPyGui holds for items and themes, by item. + highlights: The highlight colors laid on the tables, by place. + """ + + held: Dict[str, Color] + highlights: Dict[HighlightPlace, Color] diff --git a/tests/screens/interface/palettes/conftest.py b/tests/screens/interface/palettes/conftest.py new file mode 100644 index 000000000..bdcbbf5fe --- /dev/null +++ b/tests/screens/interface/palettes/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/palettes/constants.py b/tests/screens/interface/palettes/constants.py new file mode 100644 index 000000000..5d68682ba --- /dev/null +++ b/tests/screens/interface/palettes/constants.py @@ -0,0 +1,7 @@ +from typing import Final, Tuple + +from sampletones_application.categories.hierarchy import Tab + +SETTLING_FRAMES: Final[int] = 10 +TAB_FRAMES: Final[int] = 10 +EVERY_TAB: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.INSTRUCTIONS, Tab.RECONSTRUCTIONS, Tab.SEQUENCER) diff --git a/tests/screens/interface/palettes/steps.py b/tests/screens/interface/palettes/steps.py new file mode 100644 index 000000000..86885aa33 --- /dev/null +++ b/tests/screens/interface/palettes/steps.py @@ -0,0 +1,23 @@ +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import EVERY_TAB, TAB_FRAMES +from tests.suite.screens.dearpygui.items.colors import read_held_colors +from tests.suite.screens.palettes import in_fractions +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + + +def painted(screen: Screen) -> Painted: + """Reads every held color and every table highlight on the screen now.""" + return Painted( + held=screen.bridge.ask(read_held_colors), + highlights={place: in_fractions(color) for place, color in screen.table_highlights().items()}, + ) + + +def show_every_tab(screen: Screen) -> None: + """Brings each tab forward once, so every panel stands built and drawn before the colors are read.""" + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in EVERY_TAB: + screen.tabs.bring_to_front(tab) + screen.frames(TAB_FRAMES) diff --git a/tests/screens/interface/test_palettes.py b/tests/screens/interface/palettes/test_every_held_color.py similarity index 53% rename from tests/screens/interface/test_palettes.py rename to tests/screens/interface/palettes/test_every_held_color.py index 0e866d27d..2e17bc021 100644 --- a/tests/screens/interface/test_palettes.py +++ b/tests/screens/interface/palettes/test_every_held_color.py @@ -1,41 +1,12 @@ import operator -from dataclasses import dataclass -from typing import Dict, Final, List, Tuple +from typing import Dict, List, Tuple -import pytest - -from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.highlights import HighlightPlace -from tests.suite.screens.dearpygui.items import read_held_colors -from tests.suite.screens.palettes import Color, in_fractions, shared_colors, shipped_palettes +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import SETTLING_FRAMES +from tests.screens.interface.palettes.steps import painted, show_every_tab +from tests.suite.screens.palettes import Color, shared_colors, shipped_palettes from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.reconstructions import expect_open -from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION - -SETTLING_FRAMES: Final[int] = 10 -TAB_FRAMES: Final[int] = 10 -EVERY_TAB: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.INSTRUCTIONS, Tab.RECONSTRUCTIONS, Tab.SEQUENCER) -SECOND_ROUND: Final[int] = 2 -ROUNDS: Final[int] = 20 - - -@dataclass(frozen=True) -class Painted: - """Every color the interface holds at one moment: what DearPyGui holds for items and themes, and the highlights - laid on its tables. - """ - - held: Dict[str, Color] - highlights: Dict[HighlightPlace, Color] - - -def painted(screen: Screen) -> Painted: - return Painted( - held=screen.bridge.ask(read_held_colors), - highlights={place: in_fractions(color) for place, color in screen.table_highlights().items()}, - ) def unchanged(readings: List[Painted]) -> Tuple[List[Color], List[Color]]: @@ -59,23 +30,16 @@ def differing(after: Painted, before: Painted) -> Dict[str, Tuple[Color, Color]] return {key: (after.held[key], before.held[key]) for key in common if after.held[key] != before.held[key]} -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) - - -def show_every_tab(screen: Screen) -> None: - """Brings each tab forward once, so every panel stands built and drawn before the colors are read.""" - expect_open(screen, PLAYABLE_RECONSTRUCTION) - for tab in EVERY_TAB: - screen.tabs.bring_to_front(tab) - screen.frames(TAB_FRAMES) +class TestEveryHeldColorFollowsThePalette: + """Picking a palette repaints every color the interface holds. + A color that no palette changes is one every palette states alike. The table highlights follow the + palette, a round trip comes back to the colors it began with, and Cancel puts back the palette + in force before the preview. -class TestEveryHeldColorFollowsThePalette: - """A palette picked repaints every color the interface holds: what no palette changes is a color every palette - states alike, the table highlights follow, a round trip comes back to where it began, and Cancel leaves nothing - of the palette it previewed. + The scenario reads the starting palette, switches to each other palette and compares readings, + returns to the first palette, then previews another one and cancels. It ends with the starting + colors on the screen. """ def test_no_color_is_left_from_the_palette_before(self, screen: Screen) -> None: @@ -135,35 +99,3 @@ def cancel_leaves_nothing_of_the_preview(screen: Screen) -> None: the_first_palette_again_paints_as_it_began, cancel_leaves_nothing_of_the_preview, ).run() - - -class TestTwentySwaps: - """Twenty round trips between two palettes build nothing: the interface holds as many items after the twentieth - as after the second, and paints as it did. - """ - - def test_the_item_count_holds(self, screen: Screen) -> None: - settings = screen.display_settings - catalog = shipped_palettes() - other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) - counts: Dict[int, int] = {} - readings: List[Painted] = [] - - def swap_back_and_forth(screen: Screen) -> None: - show_every_tab(screen) - settings.open() - screen.expect(settings.is_shown, bool, description="Display settings") - readings.append(painted(screen)) - - for round_number in range(1, ROUNDS + 1): - settings.choose_palette(other) - settings.choose_palette(DEFAULT_PALETTE_NAME) - screen.frames(SETTLING_FRAMES) - counts[round_number] = screen.item_count() - - assert counts[ROUNDS] == counts[SECOND_ROUND] - assert painted(screen) == readings[0] - settings.cancel() - screen.expect(settings.is_shown, operator.not_, description="Display settings closed") - - screen.scenario(swap_back_and_forth).run() diff --git a/tests/screens/interface/palettes/test_twenty_swaps.py b/tests/screens/interface/palettes/test_twenty_swaps.py new file mode 100644 index 000000000..cc3663266 --- /dev/null +++ b/tests/screens/interface/palettes/test_twenty_swaps.py @@ -0,0 +1,44 @@ +import operator +from typing import Dict, Final, List + +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import SETTLING_FRAMES +from tests.screens.interface.palettes.steps import painted, show_every_tab +from tests.suite.screens.palettes import shipped_palettes +from tests.suite.screens.screen import Screen + +SECOND_ROUND: Final[int] = 2 +ROUNDS: Final[int] = 20 + + +class TestTwentySwaps: + """Twenty round trips between two palettes add no items: the interface holds as many items after the + twentieth as after the second, and paints as it did. + """ + + def test_the_item_count_holds(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + counts: Dict[int, int] = {} + readings: List[Painted] = [] + + def swap_back_and_forth(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + readings.append(painted(screen)) + + for round_number in range(1, ROUNDS + 1): + settings.choose_palette(other) + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + counts[round_number] = screen.item_count() + + assert counts[ROUNDS] == counts[SECOND_ROUND] + assert painted(screen) == readings[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(swap_back_and_forth).run() diff --git a/tests/screens/interface/shortcuts/__init__.py b/tests/screens/interface/shortcuts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/shortcuts/conftest.py b/tests/screens/interface/shortcuts/conftest.py new file mode 100644 index 000000000..bdcbbf5fe --- /dev/null +++ b/tests/screens/interface/shortcuts/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/shortcuts/constants.py b/tests/screens/interface/shortcuts/constants.py new file mode 100644 index 000000000..e20e3358d --- /dev/null +++ b/tests/screens/interface/shortcuts/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 diff --git a/tests/screens/interface/shortcuts/steps.py b/tests/screens/interface/shortcuts/steps.py new file mode 100644 index 000000000..2baca028a --- /dev/null +++ b/tests/screens/interface/shortcuts/steps.py @@ -0,0 +1,9 @@ +from sampletones_application.categories.hierarchy import Tab +from tests.screens.interface.shortcuts.constants import SETTLING_FRAMES +from tests.suite.screens.screen import Screen + + +def on_a_tab(screen: Screen, tab: Tab) -> None: + """Brings ``tab`` forward with a click, which leaves every field free of the keyboard.""" + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) diff --git a/tests/screens/interface/shortcuts/test_acting_on_documents.py b/tests/screens/interface/shortcuts/test_acting_on_documents.py new file mode 100644 index 000000000..d020750b8 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_acting_on_documents.py @@ -0,0 +1,40 @@ +import operator + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import UNTITLED, expect_open, titled +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + + +class TestShortcutsActingOnDocuments: + """Each shortcut closing or starting a document does what its entry says. + + The scenario closes the reconstruction, closes the project, starts a new project, and leaves the + application. + """ + + def test_each_acts_as_named(self, screen: Screen) -> None: + voices = screen.sequencer.voices + reconstructions = screen.reconstructions + + def documents_close_and_a_new_project_opens(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + on_a_tab(screen, Tab.SEQUENCER) + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(reconstructions.file_line, operator.not_, description="the reconstruction closed") + + screen.press_shortcut(ShortcutId.CLOSE_PROJECT) + screen.expect(voices.names, operator.not_, description="the project closed") + + screen.press_shortcut(ShortcutId.NEW_PROJECT) + + screen.expect(screen.title, titled(screen, screen.words(UNTITLED)).__eq__, description="a new project") + + def exit_leaves(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + screen.scenario(documents_close_and_a_new_project_opens, exit_leaves).run() diff --git a/tests/screens/interface/shortcuts/test_opening_dialogs.py b/tests/screens/interface/shortcuts/test_opening_dialogs.py new file mode 100644 index 000000000..41193a181 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_opening_dialogs.py @@ -0,0 +1,110 @@ +import operator +from functools import partial +from typing import Final, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.shortcuts.constants import SETTLING_FRAMES +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import read_windows +from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +DIALOG_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, str], ...]] = ( + (ShortcutId.DISPLAY_SETTINGS, TAG_SETTINGS_DISPLAY_WINDOW), + (ShortcutId.KEYBOARD_SETTINGS, TAG_SETTINGS_KEYBINDINGS_WINDOW), + (ShortcutId.AUDIO_SETTINGS, TAG_SETTINGS_AUDIO_WINDOW), + (ShortcutId.PROJECT_PROPERTIES, TAG_SETTINGS_PROPERTIES_WINDOW), + (ShortcutId.RENDER_SONG, TAG_SETTINGS_RENDER_WINDOW), +) + +FILE_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, DialogKind], ...]] = ( + (ShortcutId.SAVE_PROJECT_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_FAMITRACKER, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_BITPHASE, DialogKind.SAVE), + (ShortcutId.RECONSTRUCT_FILE, DialogKind.OPEN), + (ShortcutId.RECONSTRUCT_DIRECTORY, DialogKind.DIRECTORY), + (ShortcutId.OPEN_RECONSTRUCTION, DialogKind.OPEN), + (ShortcutId.SAVE_RECONSTRUCTION_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_RECONSTRUCTION_WAV, DialogKind.SAVE), + (ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, DialogKind.SAVE), +) + + +def window_standing(window: str) -> bool: + """Whether a window standing under ``window`` is shown. Runs on the render thread.""" + return any(reading.shown and is_tag_within(reading.alias, window) for reading in read_windows()) + + +class TestShortcutsOpeningDialogs: + """Each shortcut printed for a dialog opens that dialog, and each one printed for a file asks for that + file. + + Open project asks about the project already open first: Cancel takes the question back with no + file requested, and a second try followed by Confirm requests the file. + """ + + def test_each_opens_what_it_names(self, screen: Screen) -> None: + def each_dialog_shortcut(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, window in DIALOG_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + + screen.press_shortcut(shortcut_id) + + screen.expect(lambda: screen.bridge.ask(partial(window_standing, window)), bool, description=window) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.expect( + lambda: screen.bridge.ask(partial(window_standing, window)), + operator.not_, + description=f"{window} closed", + ) + + def each_file_shortcut(screen: Screen) -> None: + for shortcut_id, kind in FILE_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + asked = len(screen.dialog_requests()) + screen.answer_next_dialog(kind, None) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked + 1).__eq__, + description=f"the file {shortcut_id} asks for", + ) + assert screen.dialog_requests()[-1].kind == kind + + def open_project_asks_about_the_open_one_first(screen: Screen) -> None: + prompt = screen.project.replace_prompt + on_a_tab(screen, Tab.SEQUENCER) + asked = len(screen.dialog_requests()) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question about the open project") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert len(screen.dialog_requests()) == asked + screen.answer_next_dialog(DialogKind.OPEN, None) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question again") + prompt.confirm() + screen.expect( + lambda: len(screen.dialog_requests()), (asked + 1).__eq__, description="the project asked for" + ) + assert screen.dialog_requests()[-1].kind == DialogKind.OPEN + + screen.scenario(each_dialog_shortcut, each_file_shortcut, open_project_asks_about_the_open_one_first).run() diff --git a/tests/screens/interface/shortcuts/test_switching_views.py b/tests/screens/interface/shortcuts/test_switching_views.py new file mode 100644 index 000000000..2f70a92b0 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_switching_views.py @@ -0,0 +1,128 @@ +from functools import partial +from typing import Final, List, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +TAB_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, Tab], ...]] = ( + (ShortcutId.SELECT_TAB_RECONSTRUCTIONS, Tab.RECONSTRUCTIONS), + (ShortcutId.SELECT_TAB_SEQUENCER, Tab.SEQUENCER), + (ShortcutId.SELECT_TAB_INSTRUCTIONS, Tab.INSTRUCTIONS), + (ShortcutId.SELECT_TAB_MAIN, Tab.MAIN), +) + +CHANNEL_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, ChannelName], ...]] = ( + (ShortcutId.TOGGLE_CHANNEL_PULSE_1, ChannelName.PULSE1), + (ShortcutId.TOGGLE_CHANNEL_PULSE_2, ChannelName.PULSE2), + (ShortcutId.TOGGLE_CHANNEL_TRIANGLE, ChannelName.TRIANGLE), + (ShortcutId.TOGGLE_CHANNEL_NOISE, ChannelName.NOISE), +) + +CHECKED_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements, MenuElements], ...]] = ( + (ShortcutId.TOGGLE_AUTOPLAY, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY), + (ShortcutId.TOGGLE_LOOP_SONG, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG), + (ShortcutId.TOGGLE_ADVANCED_SETTINGS, MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS), +) + +FOLLOW_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements], ...]] = ( + (ShortcutId.FOLLOW_PATTERNS, MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS), + (ShortcutId.FOLLOW_OFF, MenuElements.ITEM_PLAYBACK_FOLLOW_OFF), + (ShortcutId.FOLLOW_ROWS, MenuElements.ITEM_PLAYBACK_FOLLOW_ROWS), +) + +FOLLOW_ITEMS: Final[Tuple[MenuElements, ...]] = tuple(item for _, item in FOLLOW_SHORTCUTS) + + +def checked(screen: Screen, group: MenuElements, label: str) -> bool: + """Whether the entry labeled ``label`` in the menu ``group`` carries its check mark.""" + return next(entry.checked for entry in screen.menu.entries(group) if entry.label == label) + + +def sounding(screen: Screen) -> List[bool]: + """Which channels Playback โ–ธ Channels marks as sounding, in channel order.""" + return [ + checked(screen, MenuElements.GROUP_PLAYBACK_CHANNELS, screen.channel_words(channel)) + for channel in ChannelName.items() + ] + + +def followed(screen: Screen) -> List[bool]: + """Which follow modes Playback โ–ธ Follow marks as checked, in menu order.""" + return [checked(screen, MenuElements.GROUP_PLAYBACK_FOLLOW, screen.menu.label(item)) for item in FOLLOW_ITEMS] + + +class TestShortcutsSwitchingWhatIsShown: + """Each tab, channel, mode and playback shortcut changes what its menu entry or the screen says it + changes. + + The scenario goes through the tab shortcuts, mutes and restores each channel, flips each checked + entry twice, selects each follow mode, and starts and stops playback. + """ + + def test_each_switches_its_state(self, screen: Screen) -> None: + playback = screen.sequencer.playback + + def tabs(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, tab in TAB_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(screen.tabs.front, tab.__eq__, description=f"{tab} in front") + + screen.press_shortcut(ShortcutId.NEXT_TAB) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the next tab in front") + screen.press_shortcut(ShortcutId.PREVIOUS_TAB) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the previous tab in front") + + def channels(screen: Screen) -> None: + on_a_tab(screen, Tab.SEQUENCER) + for index, (shortcut_id, channel) in enumerate(CHANNEL_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position > index for position in range(len(CHANNEL_SHORTCUTS))] + screen.expect(partial(sounding, screen), expected.__eq__, description=f"{channel} muted") + + for shortcut_id, channel in CHANNEL_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(partial(sounding, screen), all, description="every channel sounding again") + + def switches(screen: Screen) -> None: + for shortcut_id, group, item in CHECKED_SHORTCUTS: + on_a_tab(screen, Tab.SEQUENCER) + before = screen.menu.is_checked(group, item) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: screen.menu.is_checked(group, item), + (not before).__eq__, + description=f"{item} switched", + ) + screen.press_shortcut(shortcut_id) + screen.expect(lambda: screen.menu.is_checked(group, item), before.__eq__, description=f"{item} back") + + def follow_modes(screen: Screen) -> None: + for index, (shortcut_id, item) in enumerate(FOLLOW_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position == index for position in range(len(FOLLOW_ITEMS))] + screen.expect(partial(followed, screen), expected.__eq__, description=f"{item} alone checked") + + def play_and_stop(screen: Screen) -> None: + for start in (ShortcutId.PLAY, ShortcutId.PLAY_FROM_START): + screen.press_shortcut(start) + screen.expect(playback.play_entry, screen.words(PAUSE).__eq__, description=f"playing after {start}") + + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.play_entry, screen.words(PLAY).__eq__, description="stopped") + + screen.scenario(tabs, channels, switches, follow_modes, play_and_stop).run() diff --git a/tests/screens/interface/test_display.py b/tests/screens/interface/test_display.py deleted file mode 100644 index 83b17c05a..000000000 --- a/tests/screens/interface/test_display.py +++ /dev/null @@ -1,228 +0,0 @@ -import operator -from typing import Final, List, Tuple - -import pytest - -from sampletones_application.config.session.application.config import ApplicationConfig -from sampletones_application.config.session.application.display import DisplayConfig -from sampletones_application.config.session.state.window import ViewportState -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_shared.display import Resolution -from tests.suite.screens.dearpygui.items import read_viewport, read_viewport_decorated -from tests.suite.screens.screen import Screen -from tests.suite.screens.world import World, screen_filling_state -from tests.suite.screens.written import written_application_config, written_state - -SIZE_SEPARATOR: Final[str] = "x" -SEEDED_SIZE: Final[Resolution] = Resolution(width=1280, height=800) -COUNTDOWN_LIMIT_SECONDS: Final[float] = 20.0 -SETTLING_FRAMES: Final[int] = 10 - - -def size_named(label: str) -> Resolution: - """The window size a Resolution entry names, written width first.""" - width, height = label.split(SIZE_SEPARATOR) - return Resolution(width=int(width), height=int(height)) - - -def window_size(screen: Screen) -> Tuple[int, int]: - viewport = screen.bridge.ask(read_viewport) - return round(viewport.width), round(viewport.height) - - -def framed(screen: Screen) -> bool: - return screen.bridge.ask(read_viewport_decorated) - - -def open_display_settings(screen: Screen) -> None: - settings = screen.display_settings - settings.open() - screen.expect(settings.is_shown, bool, description="Display settings") - - -def another_size(screen: Screen) -> str: - """A size the Resolution list offers other than the one it names.""" - settings = screen.display_settings - current = settings.resolution() - return next(label for label in settings.resolutions() if label != current) - - -def kept(screen: Screen) -> None: - """Waits for the countdown a window change starts, and presses Keep.""" - settings = screen.display_settings - screen.expect(settings.countdown_shown, bool, description="the countdown") - settings.keep() - screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") - - -def leave(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - - assert screen.wait_for_exit() - - -class TestLeavingWritesTheWindowMode: - """A window size and a frameless window, kept on the countdown and confirmed with OK, are what leaving writes.""" - - def test_the_size_and_the_frame_are_written(self, screen: Screen) -> None: - settings = screen.display_settings - chosen: List[Resolution] = [] - - def pick_a_size_and_keep_it(screen: Screen) -> None: - open_display_settings(screen) - label = another_size(screen) - - settings.choose_resolution(label) - kept(screen) - - chosen.append(size_named(label)) - screen.expect( - lambda: window_size(screen), - (chosen[0].width, chosen[0].height).__eq__, - description="the window at the size picked", - ) - - def go_frameless_and_keep_it(screen: Screen) -> None: - assert framed(screen) - - settings.toggle_borderless() - kept(screen) - - screen.expect(lambda: framed(screen), operator.not_, description="the window without its frame") - - def confirm_and_leave(screen: Screen) -> None: - settings.confirm() - screen.expect(settings.is_shown, operator.not_, description="Display settings closed") - - leave(screen) - - viewport = written_state().viewport - assert (viewport.width, viewport.height) == (chosen[0].width, chosen[0].height) - assert written_application_config().display.borderless - - screen.scenario(pick_a_size_and_keep_it, go_frameless_and_keep_it, confirm_and_leave).run() - - -class TestAHomeHoldingAWindowModeShowsIt: - """A home whose session holds a window size and whose settings ask for a frameless window without vsync opens - the application that way, and Display settings names it. - """ - - @pytest.fixture - def world(self) -> World: - state = screen_filling_state().model_copy( - update={"viewport": ViewportState(width=SEEDED_SIZE.width, height=SEEDED_SIZE.height, x=0, y=0)} - ) - return World( - state=state, - application_config=ApplicationConfig(display=DisplayConfig(borderless=True, vsync=False)), - config=None, - files=(), - ) - - def test_the_window_opens_as_the_session_left_it(self, screen: Screen) -> None: - settings = screen.display_settings - - def the_window_stands_as_written(screen: Screen) -> None: - assert window_size(screen) == (SEEDED_SIZE.width, SEEDED_SIZE.height) - assert not framed(screen) - - def display_settings_names_it(screen: Screen) -> None: - open_display_settings(screen) - - assert settings.resolution() == str(SEEDED_SIZE) - assert settings.borderless() - assert not settings.vsync() - settings.cancel() - screen.expect(settings.is_shown, operator.not_, description="Display settings closed") - - screen.scenario(the_window_stands_as_written, display_settings_names_it).run() - - -class TestTheWindowModeCountdown: - """A window change counts down: Keep keeps it, while Revert, and the count running out, bring the window back; - Cancel then puts back the window the dialog opened with. - """ - - def test_keep_revert_and_running_out(self, screen: Screen) -> None: - settings = screen.display_settings - - def revert_brings_the_frame_back(screen: Screen) -> None: - open_display_settings(screen) - settings.toggle_borderless() - screen.expect(settings.countdown_shown, bool, description="the countdown") - assert not framed(screen) - - settings.revert() - - screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") - screen.expect(lambda: framed(screen), bool, description="the frame back") - assert not settings.borderless() - - def running_out_brings_it_back(screen: Screen) -> None: - settings.toggle_borderless() - screen.expect(settings.countdown_shown, bool, description="the countdown") - - screen.bridge.expect( - settings.countdown_shown, - operator.not_, - description="the countdown run out", - timeout=COUNTDOWN_LIMIT_SECONDS, - ) - - screen.expect(lambda: framed(screen), bool, description="the frame back") - assert not settings.borderless() - - def keep_keeps_it_until_cancel(screen: Screen) -> None: - settings.toggle_borderless() - - kept(screen) - - screen.frames(SETTLING_FRAMES) - assert not framed(screen) - assert settings.borderless() - settings.cancel() - screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") - settings.discard_prompt.confirm() - screen.expect(settings.is_shown, operator.not_, description="Display settings closed") - screen.expect(lambda: framed(screen), bool, description="the frame back") - - screen.scenario(revert_brings_the_frame_back, running_out_brings_it_back, keep_keeps_it_until_cancel).run() - - -class TestClosingWithASizeKeptButNotConfirmed: - """The window closed while Display settings holds a size kept on the countdown but never confirmed: the session - keeps the size the dialog opened with, as it does for every setting the dialog has not committed. - """ - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: closing the window writes a size Display settings never confirmed", - ) - def test_leaving_writes_the_confirmed_size(self, screen: Screen) -> None: - settings = screen.display_settings - opened: List[Tuple[int, int]] = [] - - def keep_a_size_without_confirming_it(screen: Screen) -> None: - opened.append(window_size(screen)) - open_display_settings(screen) - label = another_size(screen) - - settings.choose_resolution(label) - kept(screen) - - screen.expect( - lambda: window_size(screen), - (size_named(label).width, size_named(label).height).__eq__, - description="the window at the size picked", - ) - - def close_the_window(screen: Screen) -> None: - screen.close_window() - - assert screen.wait_for_exit() - viewport = written_state().viewport - assert (viewport.width, viewport.height) == opened[0] - - screen.scenario(keep_a_size_without_confirming_it, close_the_window).run() diff --git a/tests/screens/interface/test_shortcuts.py b/tests/screens/interface/test_shortcuts.py deleted file mode 100644 index b764cfe94..000000000 --- a/tests/screens/interface/test_shortcuts.py +++ /dev/null @@ -1,251 +0,0 @@ -import operator -from functools import partial -from typing import Final, List, Tuple - -import pytest - -from sampletones_application.categories.elements.global_ import MenuElements -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.tags.settings import ( - TAG_SETTINGS_AUDIO_WINDOW, - TAG_SETTINGS_DISPLAY_WINDOW, - TAG_SETTINGS_KEYBINDINGS_WINDOW, - TAG_SETTINGS_PROPERTIES_WINDOW, - TAG_SETTINGS_RENDER_WINDOW, -) -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.dearpygui.items import is_tag_within, read_windows -from tests.suite.screens.dearpygui.keys import IMGUI_ESCAPE -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.reconstructions import UNTITLED, expect_open, titled -from tests.suite.screens.vocabulary.playback import PAUSE, PLAY -from tests.suite.screens.world import ARRANGED_PROJECT, PLAYABLE_RECONSTRUCTION - -SETTLING_FRAMES: Final[int] = 10 -DIALOG_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, str], ...]] = ( - (ShortcutId.DISPLAY_SETTINGS, TAG_SETTINGS_DISPLAY_WINDOW), - (ShortcutId.KEYBOARD_SETTINGS, TAG_SETTINGS_KEYBINDINGS_WINDOW), - (ShortcutId.AUDIO_SETTINGS, TAG_SETTINGS_AUDIO_WINDOW), - (ShortcutId.PROJECT_PROPERTIES, TAG_SETTINGS_PROPERTIES_WINDOW), - (ShortcutId.RENDER_SONG, TAG_SETTINGS_RENDER_WINDOW), -) -FILE_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, DialogKind], ...]] = ( - (ShortcutId.SAVE_PROJECT_AS, DialogKind.SAVE), - (ShortcutId.EXPORT_PROJECT_FAMITRACKER, DialogKind.SAVE), - (ShortcutId.EXPORT_PROJECT_BITPHASE, DialogKind.SAVE), - (ShortcutId.RECONSTRUCT_FILE, DialogKind.OPEN), - (ShortcutId.RECONSTRUCT_DIRECTORY, DialogKind.DIRECTORY), - (ShortcutId.OPEN_RECONSTRUCTION, DialogKind.OPEN), - (ShortcutId.SAVE_RECONSTRUCTION_AS, DialogKind.SAVE), - (ShortcutId.EXPORT_RECONSTRUCTION_WAV, DialogKind.SAVE), - (ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, DialogKind.SAVE), -) -TAB_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, Tab], ...]] = ( - (ShortcutId.SELECT_TAB_RECONSTRUCTIONS, Tab.RECONSTRUCTIONS), - (ShortcutId.SELECT_TAB_SEQUENCER, Tab.SEQUENCER), - (ShortcutId.SELECT_TAB_INSTRUCTIONS, Tab.INSTRUCTIONS), - (ShortcutId.SELECT_TAB_MAIN, Tab.MAIN), -) -CHANNEL_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, ChannelName], ...]] = ( - (ShortcutId.TOGGLE_CHANNEL_PULSE_1, ChannelName.PULSE1), - (ShortcutId.TOGGLE_CHANNEL_PULSE_2, ChannelName.PULSE2), - (ShortcutId.TOGGLE_CHANNEL_TRIANGLE, ChannelName.TRIANGLE), - (ShortcutId.TOGGLE_CHANNEL_NOISE, ChannelName.NOISE), -) -CHECKED_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements, MenuElements], ...]] = ( - (ShortcutId.TOGGLE_AUTOPLAY, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY), - (ShortcutId.TOGGLE_LOOP_SONG, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG), - (ShortcutId.TOGGLE_ADVANCED_SETTINGS, MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS), -) -FOLLOW_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements], ...]] = ( - (ShortcutId.FOLLOW_PATTERNS, MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS), - (ShortcutId.FOLLOW_OFF, MenuElements.ITEM_PLAYBACK_FOLLOW_OFF), - (ShortcutId.FOLLOW_ROWS, MenuElements.ITEM_PLAYBACK_FOLLOW_ROWS), -) -FOLLOW_ITEMS: Final[Tuple[MenuElements, ...]] = tuple(item for _, item in FOLLOW_SHORTCUTS) - - -def window_standing(window: str) -> bool: - """Whether a window standing under ``window`` is shown. Runs on the render thread.""" - return any(reading.shown and is_tag_within(reading.alias, window) for reading in read_windows()) - - -def checked(screen: Screen, group: MenuElements, label: str) -> bool: - return next(entry.checked for entry in screen.menu.entries(group) if entry.label == label) - - -def sounding(screen: Screen) -> List[bool]: - """Which channels Playback โ–ธ Channels marks as sounding, in channel order.""" - return [ - checked(screen, MenuElements.GROUP_PLAYBACK_CHANNELS, screen.channel_words(channel)) - for channel in ChannelName.items() - ] - - -def followed(screen: Screen) -> List[bool]: - return [checked(screen, MenuElements.GROUP_PLAYBACK_FOLLOW, screen.menu.label(item)) for item in FOLLOW_ITEMS] - - -def on_a_tab(screen: Screen, tab: Tab) -> None: - """Brings ``tab`` forward with a click, which leaves no field holding the keyboard.""" - screen.tabs.bring_to_front(tab) - screen.frames(SETTLING_FRAMES) - - -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) - - -class TestShortcutsOpeningDialogs: - """Each shortcut printed for a dialog opens that dialog, and each one printed for a file asks for that file.""" - - def test_each_opens_what_it_names(self, screen: Screen) -> None: - def each_dialog_shortcut(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - for shortcut_id, window in DIALOG_SHORTCUTS: - on_a_tab(screen, Tab.RECONSTRUCTIONS) - - screen.press_shortcut(shortcut_id) - - screen.expect(lambda: screen.bridge.ask(partial(window_standing, window)), bool, description=window) - screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) - screen.expect( - lambda: screen.bridge.ask(partial(window_standing, window)), - operator.not_, - description=f"{window} closed", - ) - - def each_file_shortcut(screen: Screen) -> None: - for shortcut_id, kind in FILE_SHORTCUTS: - on_a_tab(screen, Tab.RECONSTRUCTIONS) - asked = len(screen.dialog_requests()) - screen.answer_next_dialog(kind, None) - - screen.press_shortcut(shortcut_id) - - screen.expect( - lambda: len(screen.dialog_requests()), - (asked + 1).__eq__, - description=f"the file {shortcut_id} asks for", - ) - assert screen.dialog_requests()[-1].kind == kind - - def open_project_asks_about_the_open_one_first(screen: Screen) -> None: - prompt = screen.project.replace_prompt - on_a_tab(screen, Tab.SEQUENCER) - asked = len(screen.dialog_requests()) - screen.press_shortcut(ShortcutId.OPEN_PROJECT) - screen.expect(prompt.is_shown, bool, description="the question about the open project") - - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question taken back") - screen.frames(SETTLING_FRAMES) - assert len(screen.dialog_requests()) == asked - screen.answer_next_dialog(DialogKind.OPEN, None) - screen.press_shortcut(ShortcutId.OPEN_PROJECT) - screen.expect(prompt.is_shown, bool, description="the question again") - prompt.confirm() - screen.expect( - lambda: len(screen.dialog_requests()), (asked + 1).__eq__, description="the project asked for" - ) - assert screen.dialog_requests()[-1].kind == DialogKind.OPEN - - screen.scenario(each_dialog_shortcut, each_file_shortcut, open_project_asks_about_the_open_one_first).run() - - -class TestShortcutsSwitchingWhatIsShown: - """Each tab, channel, mode and playback shortcut changes what its menu entry or the screen says it changes.""" - - def test_each_switches_its_state(self, screen: Screen) -> None: - playback = screen.sequencer.playback - - def tabs(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - for shortcut_id, tab in TAB_SHORTCUTS: - screen.press_shortcut(shortcut_id) - - screen.expect(screen.tabs.front, tab.__eq__, description=f"{tab} in front") - - screen.press_shortcut(ShortcutId.NEXT_TAB) - screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the next tab in front") - screen.press_shortcut(ShortcutId.PREVIOUS_TAB) - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the previous tab in front") - - def channels(screen: Screen) -> None: - on_a_tab(screen, Tab.SEQUENCER) - for index, (shortcut_id, channel) in enumerate(CHANNEL_SHORTCUTS): - screen.press_shortcut(shortcut_id) - - expected = [position > index for position in range(len(CHANNEL_SHORTCUTS))] - screen.expect(partial(sounding, screen), expected.__eq__, description=f"{channel} muted") - - for shortcut_id, channel in CHANNEL_SHORTCUTS: - screen.press_shortcut(shortcut_id) - - screen.expect(partial(sounding, screen), all, description="every channel sounding again") - - def switches(screen: Screen) -> None: - for shortcut_id, group, item in CHECKED_SHORTCUTS: - on_a_tab(screen, Tab.SEQUENCER) - before = screen.menu.is_checked(group, item) - - screen.press_shortcut(shortcut_id) - - screen.expect( - lambda: screen.menu.is_checked(group, item), - (not before).__eq__, - description=f"{item} switched", - ) - screen.press_shortcut(shortcut_id) - screen.expect(lambda: screen.menu.is_checked(group, item), before.__eq__, description=f"{item} back") - - def follow_modes(screen: Screen) -> None: - for index, (shortcut_id, item) in enumerate(FOLLOW_SHORTCUTS): - screen.press_shortcut(shortcut_id) - - expected = [position == index for position in range(len(FOLLOW_ITEMS))] - screen.expect(partial(followed, screen), expected.__eq__, description=f"{item} alone checked") - - def play_and_stop(screen: Screen) -> None: - for start in (ShortcutId.PLAY, ShortcutId.PLAY_FROM_START): - screen.press_shortcut(start) - screen.expect(playback.play_entry, screen.words(PAUSE).__eq__, description=f"playing after {start}") - - screen.press_shortcut(ShortcutId.STOP) - - screen.expect(playback.play_entry, screen.words(PLAY).__eq__, description="stopped") - - screen.scenario(tabs, channels, switches, follow_modes, play_and_stop).run() - - -class TestShortcutsActingOnDocuments: - """Each shortcut closing or starting a document does what its entry says.""" - - def test_each_acts_as_named(self, screen: Screen) -> None: - voices = screen.sequencer.voices - reconstructions = screen.reconstructions - - def documents_close_and_a_new_project_opens(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - on_a_tab(screen, Tab.SEQUENCER) - screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) - screen.expect(reconstructions.file_line, operator.not_, description="the reconstruction closed") - - screen.press_shortcut(ShortcutId.CLOSE_PROJECT) - screen.expect(voices.names, operator.not_, description="the project closed") - - screen.press_shortcut(ShortcutId.NEW_PROJECT) - - screen.expect(screen.title, titled(screen, screen.words(UNTITLED)).__eq__, description="a new project") - - def exit_leaves(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - - assert screen.wait_for_exit() - - screen.scenario(documents_close_and_a_new_project_opens, exit_leaves).run() diff --git a/tests/screens/main/cards/__init__.py b/tests/screens/main/cards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/test_cards.py b/tests/screens/main/cards/test_card_layout.py similarity index 79% rename from tests/screens/main/test_cards.py rename to tests/screens/main/cards/test_card_layout.py index 6d463eb4d..856ac4fd8 100644 --- a/tests/screens/main/test_cards.py +++ b/tests/screens/main/cards/test_card_layout.py @@ -12,11 +12,13 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import read_item, read_scroll +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import read_scroll from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording +from tests.suite.screens.seeds.recordings import Recording from tests.suite.screens.steps.main import gather, home_path -from tests.suite.screens.world import World, lived_in_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world PAST_THE_DIALOG: Final[int] = 30 SECONDS: Final[float] = 0.2 @@ -25,21 +27,25 @@ def box(screen: Screen, tag: str) -> Rect: + """Returns the rectangle of the item ``tag``.""" rect = screen.bridge.ask(lambda: read_item(tag).rect) assert rect is not None, tag return rect def bottom(rect: Rect) -> float: + """Returns the lower edge of ``rect``.""" return rect.y + rect.height def right(rect: Rect) -> float: + """Returns the right edge of ``rect``.""" return rect.x + rect.width @pytest.fixture def world() -> World: + """The home holds thirty short voices, and the saved state shows the Advanced settings.""" state = lived_in_world().state assert state is not None files = tuple( @@ -55,9 +61,14 @@ def world() -> World: class TestTheCardsOfTheMainTab: - """The Main tab reads General settings, the Converter and Source settings, top to bottom, and the row of settings cards keeps its line.""" + """The Main tab stacks General settings, the Converter and Source settings from top to bottom, and the + Advanced card keeps General's line. + """ def test_the_order_and_the_shared_line(self, screen: Screen) -> None: + """The cards stand in order, General and Advanced end on one line, and Advanced lies right of + General. + """ general = box(screen, TAG_MAIN_CONFIG_PANEL) advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) converter = box(screen, TAG_MAIN_CONVERTER_PANEL) @@ -68,6 +79,10 @@ def test_the_order_and_the_shared_line(self, screen: Screen) -> None: assert right(general) < advanced.x def test_advanced_stands_beside_general_ending_where_the_converter_does(self, screen: Screen) -> None: + """After Advanced is put away and shown again, it stands beside General and ends at the Converter's + right edge. + """ + def advanced_off_and_on_again(screen: Screen) -> None: screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") @@ -93,6 +108,7 @@ def the_two_side_by_side(screen: Screen) -> None: reason="bugs-and-todos ยง Bugs: General settings stops short of the Converter's edge with Advanced put away", ) def test_general_fills_the_row_with_advanced_put_away(self, screen: Screen) -> None: + """With Advanced put away, General ends at the Converter's right edge.""" screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") @@ -103,6 +119,7 @@ def test_general_fills_the_row_with_advanced_put_away(self, screen: Screen) -> N ) def test_collapsing_general_alone_gives_up_the_rows_height(self, screen: Screen) -> None: + """Collapsing General with Advanced put away shrinks the card and lifts the Converter to meet it.""" general = screen.main.card(TAG_MAIN_CONFIG_PANEL) heights: List[float] = [] @@ -129,11 +146,13 @@ def collapse_general(screen: Screen) -> None: class TestAddingAfterScrollingThePick: """A mix picked from a scrolled list closes on Add, and the card answers the next gesture. - The checklist asks it of twelve recordings; the question holds twelve without scrolling on this - screen, so the scenario gathers as many as make it scroll. + The checklist asks it of twelve recordings. The question fits twelve without scrolling on this + screen, so the scenario gathers as many as make it scroll. It scrolls the pick list to the end and + presses Add. A click on the first row then shows that recording on the Source card. """ def test_the_question_closes_and_the_card_answers(self, screen: Screen) -> None: + """Add closes the question and the next row pick reaches the Source card.""" main = screen.main question = main.converter.mix_question paths = [home_path(f"voice{index:02d}.wav") for index in range(PAST_THE_DIALOG)] diff --git a/tests/screens/main/gathering/__init__.py b/tests/screens/main/gathering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/gathering/conftest.py b/tests/screens/main/gathering/conftest.py new file mode 100644 index 000000000..38a382a39 --- /dev/null +++ b/tests/screens/main/gathering/conftest.py @@ -0,0 +1,59 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from tests.screens.main.gathering.constants import ( + COLLIDING, + INNER, + LOOPS, + LOOPS_HELD, + NOTES, + TAKES, + TAKES_AT_THE_TOP, + TAKES_INSIDE, +) +from tests.suite.screens.seeds.archives import WrittenBytes +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import lived_in_world + +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.2 +TONE_FREQUENCY: Final[float] = 220.0 +NOTE_TEXT: Final[bytes] = b"A folder holding words, not sounds.\n" + + +def recording(path: Path, seconds: float) -> HomeFile: + """Returns a seeded tone recording of ``seconds`` to be written at ``path``.""" + return Recording(destination=path, seconds=seconds, frequency=TONE_FREQUENCY) + + +def gathering_world() -> World: + """Returns the lived-in world plus two long recordings, the Takes folder with a subfolder, the Loops + folder, three recordings with colliding names and a Notes folder holding only a text file. + """ + files: List[HomeFile] = [ + recording(home_path(KICK), PLAYED_SECONDS), + recording(home_path(SNARE), PLAYED_SECONDS), + *(recording(home_path(TAKES) / name, SHORT_SECONDS) for name in TAKES_AT_THE_TOP), + *(recording(home_path(TAKES) / INNER / name, SHORT_SECONDS) for name in TAKES_INSIDE), + *(recording(home_path(LOOPS) / name, SHORT_SECONDS) for name in LOOPS_HELD), + *(recording(home_path(name), SHORT_SECONDS) for name in COLLIDING), + WrittenBytes(home_path(NOTES) / INNER / "readme.txt", NOTE_TEXT), + ] + world = lived_in_world() + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(files), + ) + + +@pytest.fixture +def world() -> World: + """The home holds the recordings and folders the gathering scenarios pick from.""" + return gathering_world() diff --git a/tests/screens/main/gathering/constants.py b/tests/screens/main/gathering/constants.py new file mode 100644 index 000000000..2733d40aa --- /dev/null +++ b/tests/screens/main/gathering/constants.py @@ -0,0 +1,10 @@ +from typing import Final, Tuple + +TAKES: Final[str] = "Takes" +INNER: Final[str] = "Inner" +LOOPS: Final[str] = "Loops" +NOTES: Final[str] = "Notes" +TAKES_AT_THE_TOP: Final[Tuple[str, ...]] = ("one.wav", "two.wav", "three.wav") +TAKES_INSIDE: Final[Tuple[str, ...]] = ("four.wav", "five.wav") +LOOPS_HELD: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +COLLIDING: Final[Tuple[str, ...]] = ("Kick Loop.wav", "kick loop.wav", "ลผรณล‚w.wav") diff --git a/tests/screens/main/gathering/steps.py b/tests/screens/main/gathering/steps.py new file mode 100644 index 000000000..73ff3d689 --- /dev/null +++ b/tests/screens/main/gathering/steps.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from tests.suite.screens.screen import Screen + + +def gathered(screen: Screen, *paths: Path) -> None: + """Waits until the list holds a row for each of ``paths`` and no other row.""" + converter = screen.main.converter + expected = sorted(converter.list.row(path) for path in paths) + screen.expect( + lambda: sorted(converter.list.rows()), + expected.__eq__, + description=f"the list holding {[path.name for path in paths]} alone", + ) diff --git a/tests/screens/main/gathering/test_card_header.py b/tests/screens/main/gathering/test_card_header.py new file mode 100644 index 000000000..4092efa2e --- /dev/null +++ b/tests/screens/main/gathering/test_card_header.py @@ -0,0 +1,37 @@ +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_COLLAPSE_STRIP, + TAG_GLOBAL_THEME_COLLAPSE_HEADER, + TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED, +) +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_SOURCE_PANEL +from tests.suite.screens.screen import Screen + + +class TestACardHeaderUnderThePointer: + """A card's header bar lights while the pointer rests on it and settles back once the pointer leaves. + + The pointer rests on the Converter's header bar, which lights. Then it moves to the Source settings + header, and the first bar returns to its resting theme. + """ + + def test_the_bar_lights_and_settles(self, screen: Screen) -> None: + strip = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_COLLAPSE_STRIP) + elsewhere = compose_tag(TAG_MAIN_SOURCE_PANEL, SUF_COLLAPSE_STRIP) + + def rest_on_the_bar(screen: Screen) -> None: + screen.hand.hover(strip) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED.__eq__, description="lit" + ) + + def move_off(screen: Screen) -> None: + screen.hand.scroll_into_view(elsewhere) + screen.hand.hover(elsewhere) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER.__eq__, description="settled" + ) + + screen.scenario(rest_on_the_bar, move_off).run() diff --git a/tests/screens/main/gathering/test_folders_and_edges.py b/tests/screens/main/gathering/test_folders_and_edges.py new file mode 100644 index 000000000..00f4292aa --- /dev/null +++ b/tests/screens/main/gathering/test_folders_and_edges.py @@ -0,0 +1,145 @@ +import operator +from pathlib import Path +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.gathering.constants import ( + COLLIDING, + LOOPS, + LOOPS_HELD, + NOTES, + TAKES, + TAKES_AT_THE_TOP, + TAKES_INSIDE, +) +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK + +ADD_FOLDER: Final[str] = "main.explorer.label.context_add_folder_stems" +NOTHING_BELOW: Final[str] = "main.converter.message.scan_nothing_below" + + +def folder_label(screen: Screen, path: Path, count: int) -> str: + """Returns the text a folder row shows for ``path`` holding ``count`` recordings.""" + return screen.words(FOLDER_ROW).format(name=path.name, count=count) + + +def wait_for_the_scan_to_end(screen: Screen) -> None: + """Waits until the Converter has finished reading the folders it was given.""" + screen.expect(lambda: not screen.main.converter.scan_shown(), bool, description="the folder read") + + +class TestGatheringAFolder: + """A folder lands as one row counting its recordings, subfolders included, and starts no run. + + A recording is gathered first. Ctrl-click on the Takes folder and Add folder from the menu of the + Loops folder each add one counted row. The scenario ends with no run on screen and the recording + still on top. + """ + + def test_ctrl_click_and_add_folder_each_give_one_counted_row(self, screen: Screen) -> None: + """Each gesture gives its folder one row with the count of the recordings inside.""" + converter = screen.main.converter + + def gather_a_recording_first(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + + def ctrl_click_a_folder(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(TAKES))) + + gathered(screen, home_path(KICK), home_path(TAKES)) + wait_for_the_scan_to_end(screen) + count = len(TAKES_AT_THE_TOP) + len(TAKES_INSIDE) + assert converter.list.label(home_path(TAKES)) == folder_label(screen, home_path(TAKES), count) + + def add_folder_from_its_menu(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(LOOPS))) + screen.expect(screen.context_menu.is_shown, bool, description="the folder's menu") + + screen.context_menu.choose(screen.words(ADD_FOLDER)) + + gathered(screen, home_path(KICK), home_path(TAKES), home_path(LOOPS)) + wait_for_the_scan_to_end(screen) + assert converter.list.label(home_path(LOOPS)) == folder_label(screen, home_path(LOOPS), len(LOOPS_HELD)) + + def no_run_started_and_the_recording_stands_first(screen: Screen) -> None: + assert not converter.run_shown() + assert converter.action() != screen.words(CANCEL_RUN) + assert converter.list.rows()[0] == converter.list.row(home_path(KICK)) + + screen.scenario( + gather_a_recording_first, + ctrl_click_a_folder, + add_folder_from_its_menu, + no_run_started_and_the_recording_stands_first, + ).run() + + +class TestGatheringAtTheEdges: + """Gathering copes with its edges: a folder gathered twice, a folder with no recordings and names that + collide. + """ + + def test_a_folder_gathered_twice_stands_once(self, screen: Screen) -> None: + """Ctrl-clicking the same folder twice leaves one row for it.""" + for _ in range(2): + screen.explorer.ctrl_click(explorer_row(screen, home_path(TAKES))) + wait_for_the_scan_to_end(screen) + + gathered(screen, home_path(TAKES)) + + def test_a_folder_of_no_recordings_says_so_and_gathers_nothing(self, screen: Screen) -> None: + """A folder holding only words shows a notice and adds no row; the next folder of sounds gathers + once the notice is dismissed. + """ + converter = screen.main.converter + notice = converter.nothing_below_notice + + def ctrl_click_a_folder_of_words(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(NOTES))) + + screen.expect(notice.is_shown, bool, description="the notice") + assert notice.words() == screen.words(NOTHING_BELOW) + + def dismiss_and_gather_a_folder_of_sounds(screen: Screen) -> None: + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(LOOPS))) + + gathered(screen, home_path(LOOPS)) + + screen.scenario(ctrl_click_a_folder_of_words, dismiss_and_gather_a_folder_of_sounds).run() + + def test_names_colliding_in_case_and_spacing_stand_apart(self, screen: Screen) -> None: + """Recordings whose names differ only in case or spacing get separate rows, and ticking a box on + one leaves the others as they were. + """ + converter = screen.main.converter + paths = [home_path(name) for name in COLLIDING] + + def gather_each(screen: Screen) -> None: + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + + gathered(screen, *paths) + + def a_box_on_one_leaves_the_others(screen: Screen) -> None: + first, *others = paths + before = [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] + + converter.list.tick(first, ChannelName.PULSE1) + + screen.expect( + lambda: converter.list.channel_ticked(first, ChannelName.PULSE1), + (not before[0]).__eq__, + description="the box flipped on the first", + ) + assert [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] == before + + screen.scenario(gather_each, a_box_on_one_leaves_the_others).run() diff --git a/tests/screens/main/gathering/test_navigating_and_clicking.py b/tests/screens/main/gathering/test_navigating_and_clicking.py new file mode 100644 index 000000000..f8bc563e3 --- /dev/null +++ b/tests/screens/main/gathering/test_navigating_and_clicking.py @@ -0,0 +1,81 @@ +from functools import partial +from typing import Final + +from tests.screens.main.gathering.constants import INNER, LOOPS, TAKES +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.converter import CONVERT_NOTHING, CONVERT_ONE +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +ADD_AS_STEM: Final[str] = "main.explorer.label.context_add_stem" + + +class TestNavigatingGathersNothing: + """Opening folders in the explorer leaves the list empty; a double-click on a recording gathers that + one. + + The scenario opens three folders and expects the hint, an empty list and the idle Convert button. A + double-click on a recording then gives the list one row and names that recording on the button. + """ + + def test_the_list_stays_empty_until_a_recording_is_double_clicked(self, screen: Screen) -> None: + """The list holds no row until a recording is double-clicked.""" + converter = screen.main.converter + + def open_folders(screen: Screen) -> None: + for folder in (home_path(TAKES), home_path(TAKES) / INNER, home_path(LOOPS)): + row = explorer_row(screen, folder) + + screen.explorer.open_by_click(row) + + screen.expect(partial(screen.explorer.is_open, row), bool, description=f"{folder.name} open") + + def the_list_stays_empty(screen: Screen) -> None: + assert converter.list.hint_shown() + assert not converter.list.list_shown() + assert converter.action() == screen.words(CONVERT_NOTHING) + + def a_double_click_gathers_that_recording(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(KICK).stem) + assert not converter.list.hint_shown() + + screen.scenario(open_folders, the_list_stays_empty, a_double_click_gathers_that_recording).run() + + +class TestAPlainClickOnARecording: + """A plain click plays a recording; Ctrl-click and Add as stem gather it. + + A click starts playback and the hint stays. Ctrl-click on the same recording gathers it. Add as stem + from another recording's menu gathers that one too, and the menu closes. + """ + + def test_it_plays_and_the_gathering_gestures_gather(self, screen: Screen) -> None: + """A click only plays, while Ctrl-click and the Add as stem entry add rows.""" + converter = screen.main.converter + playback = screen.sequencer.playback + + def a_click_plays(screen: Screen) -> None: + screen.explorer.click(explorer_row(screen, home_path(KICK))) + + screen.expect(playback.can_stop, bool, description="the recording playing") + assert converter.list.hint_shown() + + def ctrl_click_gathers(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + + def add_as_stem_gathers(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(SNARE))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + + screen.context_menu.choose(screen.words(ADD_AS_STEM)) + + gathered(screen, home_path(KICK), home_path(SNARE)) + assert not screen.context_menu.is_shown() + + screen.scenario(a_click_plays, ctrl_click_gathers, add_as_stem_gathers).run() diff --git a/tests/screens/main/gathering/test_right_clicking.py b/tests/screens/main/gathering/test_right_clicking.py new file mode 100644 index 000000000..bbdf5ab5d --- /dev/null +++ b/tests/screens/main/gathering/test_right_clicking.py @@ -0,0 +1,68 @@ +import operator +from typing import Final, List + +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +ROUNDS: Final[int] = 20 +FIRST_COUNTED_ROUND: Final[int] = 2 +POINTER_REACH: Final[int] = 4 + + +class TestRightClickingOverAndOver: + """Twenty right-clicks, alternating between the explorer and the list, each open a menu at the pointer + and leave the interface as large as it was. + + A recording is gathered first. Each round opens the menu, reads its box and entries and dismisses + it. The scenario ends when the item count of the last round equals that of the second round. + """ + + def test_each_menu_stands_at_the_pointer_and_the_interface_does_not_grow(self, screen: Screen) -> None: + """Each menu overlaps the pointer, has labeled entries and the item count stays level.""" + converter = screen.main.converter + menu = screen.context_menu + counts: List[int] = [] + + def gather(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + gathered(screen, home_path(KICK)) + + def right_click_over_and_over(screen: Screen) -> None: + for round_number in range(1, ROUNDS + 1): + in_the_list = round_number % 2 == 0 + row = converter.list.row(home_path(KICK)) if in_the_list else explorer_row(screen, home_path(SNARE)) + if in_the_list: + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + else: + screen.explorer.right_click(row) + screen.expect(menu.is_shown, bool, description=f"the menu of round {round_number}") + pointer = screen.pointer() + + box = menu.box() + entries = menu.entries() + menu.dismiss() + + screen.expect(menu.is_shown, operator.not_, description=f"the menu of round {round_number} put away") + assert ( + box.overlap( + Rect( + x=pointer.x - POINTER_REACH, + y=pointer.y - POINTER_REACH, + width=2 * POINTER_REACH, + height=2 * POINTER_REACH, + ) + ) + is not None + ), (box, pointer) + assert entries and all(entry.enabled or entry.label for entry in entries) + if round_number >= FIRST_COUNTED_ROUND: + counts.append(screen.item_count()) + + def nothing_grew(screen: Screen) -> None: + assert counts[-1] == counts[0], counts + + screen.scenario(gather, right_click_over_and_over, nothing_grew).run() diff --git a/tests/screens/main/library/__init__.py b/tests/screens/main/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/library/steps.py b/tests/screens/main/library/steps.py new file mode 100644 index 000000000..ea48fc0d8 --- /dev/null +++ b/tests/screens/main/library/steps.py @@ -0,0 +1,16 @@ +from sampletones_core.configs import Config +from tests.suite.screens.seeds.libraries import MiniLibrary +from tests.suite.screens.worlds.home import World, screen_filling_state + + +def library_world(config: Config, *, built: bool) -> World: + """A world with Advanced settings on and ``config`` in force; with ``built`` set, the home also holds + the library that ``config`` describes. + """ + state = screen_filling_state().model_copy(update={"advanced_settings": True}) + return World( + state=state, + application_config=None, + config=config, + files=(MiniLibrary(config),) if built else (), + ) diff --git a/tests/screens/main/test_library.py b/tests/screens/main/library/test_folders_and_generators.py similarity index 52% rename from tests/screens/main/test_library.py rename to tests/screens/main/library/test_folders_and_generators.py index f4f5dcc87..95920d895 100644 --- a/tests/screens/main/test_library.py +++ b/tests/screens/main/library/test_folders_and_generators.py @@ -1,54 +1,29 @@ import operator -from functools import partial from typing import Final, List import dearpygui.dearpygui as dpg import pytest from sampletones_application.categories.hierarchy import Tab -from sampletones_core.configs import Config -from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.structures.tree.node import GeneratorNode from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.screens.main.library.steps import library_world from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.dearpygui.items import Item, find_item +from tests.suite.screens.dearpygui.items.reading import find_item +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import MiniLibrary, archived_library +from tests.suite.screens.seeds.archives import archived_library from tests.suite.screens.steps.instructions import library_path, library_row, open_library_row from tests.suite.screens.steps.main import home_path -from tests.suite.screens.world import World, one_worker_config, screen_filling_state +from tests.suite.screens.vocabulary.libraries import LIBRARY_LOADED +from tests.suite.screens.worlds.home import World, one_worker_config -QUICK_SAMPLE_RATE: Final[int] = 11025 -GENERATION_TIMEOUT_SECONDS: Final[float] = 300.0 OTHER_LIBRARIES: Final[str] = "Other libraries" -LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" -LIBRARY_MISSING: Final[str] = "instructions.library.template.library_not_exists_template" -GENERATE: Final[str] = "instructions.library.label.generate_library_button" -REGENERATE: Final[str] = "instructions.library.label.regenerate_library_button" -GENERATION_CANCELED: Final[str] = "instructions.library.message.status_generation_canceled" - - -def quick_config() -> Config: - """Settings whose library generates in seconds: a low sample rate, measured by the plain spectrum.""" - config = one_worker_config() - library = config.library.model_copy( - update={"sample_rate": QUICK_SAMPLE_RATE, "spectrum_method": SpectrumMethod.FFT} - ) - return config.model_copy(update={"library": library}) - - -def library_world(config: Config, *, built: bool) -> World: - state = screen_filling_state().model_copy(update={"advanced_settings": True}) - return World( - state=state, - application_config=None, - config=config, - files=(MiniLibrary(config),) if built else (), - ) - def generator_rows(screen: Screen, row: Item) -> List[Item]: + """The generator rows below the library ``row``, read on the render thread.""" + def read() -> List[Item]: found: List[Item] = [] find_item(row, lambda item: _collect_generator(item, found)) @@ -65,113 +40,18 @@ def _collect_generator(item: Item, found: List[Item]) -> bool: return False -class TestGeneratingALibrary: - """A configuration with no library generates one, which the tree lists and the status reads as loaded.""" - - @pytest.fixture - def world(self) -> World: - return library_world(quick_config(), built=False) - - def test_generate_lists_it_loaded_and_offers_regenerate(self, screen: Screen) -> None: - library = screen.instructions.library - path = library_path(quick_config()) - - def nothing_to_list(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.INSTRUCTIONS) - - screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered") - assert library.row(path.name) is None - - def generate(screen: Screen) -> None: - library.generate() - - row = screen.bridge.expect( - partial(library.row, path.name), - lambda found: found is not None, - description="the library listed", - timeout=GENERATION_TIMEOUT_SECONDS, - ) - assert row is not None - name = library.tree.label(row) - screen.bridge.expect( - library.status, - screen.words(LIBRARY_LOADED).format(name).__eq__, - description="the library loaded", - timeout=GENERATION_TIMEOUT_SECONDS, - ) - assert library.generate_label() == screen.words(REGENERATE) - assert path.exists() - - screen.scenario(nothing_to_list, generate).run() - - def test_cancelling_a_generation_leaves_no_library(self, screen: Screen) -> None: - library = screen.instructions.library - path = library_path(quick_config()) - - def start_and_cancel(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.INSTRUCTIONS) - library.generate() - - library.cancel_generation() - - screen.bridge.expect( - library.notice.is_shown, bool, description="the cancel reported", timeout=GENERATION_TIMEOUT_SECONDS - ) - assert library.notice.words() == screen.words(GENERATION_CANCELED) - library.notice.dismiss() - screen.expect(library.notice.is_shown, operator.not_, description="the report dismissed") - - def nothing_was_written(screen: Screen) -> None: - screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered again") - - assert not path.exists() - assert library.row(path.name) is None - assert library.status().endswith(screen.words(LIBRARY_MISSING).format("")) - - screen.scenario(start_and_cancel, nothing_was_written).run() - - -class TestRegeneratingALibrary: - """Regenerate asks first, and Cancel keeps the library as it stands.""" - - @pytest.fixture - def world(self) -> World: - return library_world(one_worker_config(), built=True) - - def test_cancel_keeps_it_and_the_next_press_asks_again(self, screen: Screen) -> None: - library = screen.instructions.library - prompt = library.regenerate_prompt - path = library_path(one_worker_config()) - kept: List[bytes] = [] - - def regenerate_asks(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.INSTRUCTIONS) - library_row(screen, path) - kept.append(path.read_bytes()) - - library.generate() - - screen.expect(prompt.is_shown, bool, description="the question") - - def cancel_keeps_it(screen: Screen) -> None: - prompt.cancel() - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - - library.generate() - - screen.expect(prompt.is_shown, bool, description="the question asked again") - prompt.cancel() - screen.expect(prompt.is_shown, operator.not_, description="the question gone again") - assert path.read_bytes() == kept[0] - - screen.scenario(regenerate_asks, cancel_keeps_it).run() - - class TestAnotherLibraryFolder: - """Advanced settings pointed at another folder lists its libraries; pointed back, the first returns loaded.""" + """Advanced settings pointed at another folder list that folder's libraries; pointed back, the first + folder's library returns loaded. + + The first library is opened and reads loaded. The library folder is pointed at a folder holding an + older library, where the first library is gone and the older one is flagged outdated. Pointing back + lists the first library again and clears the older one. + """ @pytest.fixture def world(self) -> World: + """A home holding a built library and a folder of other libraries holding an older one.""" config = one_worker_config() world = library_world(config, built=True) return World( @@ -182,6 +62,7 @@ def world(self) -> World: ) def test_each_folder_lists_its_own(self, screen: Screen) -> None: + """Each library folder lists its own libraries and none of the other's.""" library = screen.instructions.library main = screen.main ours = library_path(one_worker_config()) @@ -228,6 +109,7 @@ def point_back(screen: Screen) -> None: reason="bugs-and-todos ยง Bugs: a library folder pointed away from and back lists its library unloaded", ) def test_pointing_back_finds_the_library_loaded(self, screen: Screen) -> None: + """After pointing away and back, the first library's status reads loaded without a second opening.""" library = screen.instructions.library main = screen.main ours = library_path(one_worker_config()) @@ -248,10 +130,15 @@ def test_pointing_back_finds_the_library_loaded(self, screen: Screen) -> None: class TestGeneratorsUnderALibrary: - """A library this build reads opens onto its generators; one another version built opens onto none.""" + """A library this build reads opens onto its generators; one another version built opens onto none. + + The old library's row asks to rebuild when opened, and Cancel keeps it closed. The current + library's row opens onto generator rows while the old one still lists none. + """ @pytest.fixture def world(self) -> World: + """A home holding a built library and an older library in the library folder.""" config = one_worker_config() world = library_world(config, built=True) return World( diff --git a/tests/screens/main/library/test_generating.py b/tests/screens/main/library/test_generating.py new file mode 100644 index 000000000..65e7fcdc8 --- /dev/null +++ b/tests/screens/main/library/test_generating.py @@ -0,0 +1,140 @@ +import operator +from functools import partial +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from tests.screens.main.library.steps import library_world +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.instructions import library_path, library_row +from tests.suite.screens.vocabulary.libraries import LIBRARY_LOADED +from tests.suite.screens.worlds.home import World, one_worker_config + +QUICK_SAMPLE_RATE: Final[int] = 11025 +GENERATION_TIMEOUT_SECONDS: Final[float] = 300.0 +LIBRARY_MISSING: Final[str] = "instructions.library.template.library_not_exists_template" +GENERATE: Final[str] = "instructions.library.label.generate_library_button" +REGENERATE: Final[str] = "instructions.library.label.regenerate_library_button" +GENERATION_CANCELED: Final[str] = "instructions.library.message.status_generation_canceled" + + +def quick_config() -> Config: + """Settings whose library generates in seconds: a low sample rate, measured by the plain spectrum.""" + config = one_worker_config() + library = config.library.model_copy( + update={"sample_rate": QUICK_SAMPLE_RATE, "spectrum_method": SpectrumMethod.FFT} + ) + return config.model_copy(update={"library": library}) + + +class TestGeneratingALibrary: + """A configuration with no library generates one, which the tree lists and the status reads as loaded.""" + + @pytest.fixture + def world(self) -> World: + """A home holding no library for the quick settings.""" + return library_world(quick_config(), built=False) + + def test_generate_lists_it_loaded_and_offers_regenerate(self, screen: Screen) -> None: + """Generate lists the library, the status reads it loaded, and the button then offers Regenerate.""" + library = screen.instructions.library + path = library_path(quick_config()) + + def nothing_to_list(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered") + assert library.row(path.name) is None + + def generate(screen: Screen) -> None: + library.generate() + + row = screen.bridge.expect( + partial(library.row, path.name), + lambda found: found is not None, + description="the library listed", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert row is not None + name = library.tree.label(row) + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the library loaded", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert library.generate_label() == screen.words(REGENERATE) + assert path.exists() + + screen.scenario(nothing_to_list, generate).run() + + def test_cancelling_a_generation_leaves_no_library(self, screen: Screen) -> None: + """Canceling a generation reports it; Generate is offered again and the library has no file or row.""" + library = screen.instructions.library + path = library_path(quick_config()) + + def start_and_cancel(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library.generate() + + library.cancel_generation() + + screen.bridge.expect( + library.notice.is_shown, bool, description="the cancel reported", timeout=GENERATION_TIMEOUT_SECONDS + ) + assert library.notice.words() == screen.words(GENERATION_CANCELED) + library.notice.dismiss() + screen.expect(library.notice.is_shown, operator.not_, description="the report dismissed") + + def nothing_was_written(screen: Screen) -> None: + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered again") + + assert not path.exists() + assert library.row(path.name) is None + assert library.status().endswith(screen.words(LIBRARY_MISSING).format("")) + + screen.scenario(start_and_cancel, nothing_was_written).run() + + +class TestRegeneratingALibrary: + """Regenerate asks first, and Cancel keeps the library as it stands. + + The built library is listed and Regenerate pressed. Cancel on the question keeps the file's bytes, + and the next press asks again. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library.""" + return library_world(one_worker_config(), built=True) + + def test_cancel_keeps_it_and_the_next_press_asks_again(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.regenerate_prompt + path = library_path(one_worker_config()) + kept: List[bytes] = [] + + def regenerate_asks(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library_row(screen, path) + kept.append(path.read_bytes()) + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question") + + def cancel_keeps_it(screen: Screen) -> None: + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question asked again") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone again") + assert path.read_bytes() == kept[0] + + screen.scenario(regenerate_asks, cancel_keeps_it).run() diff --git a/tests/screens/main/list/__init__.py b/tests/screens/main/list/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/list/conftest.py b/tests/screens/main/list/conftest.py new file mode 100644 index 000000000..db355f808 --- /dev/null +++ b/tests/screens/main/list/conftest.py @@ -0,0 +1,24 @@ +from typing import Final, List + +import pytest + +from tests.screens.main.list.constants import FORTY, FORTY_COUNT +from tests.screens.main.list.steps import recording, take +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import lived_in_world + +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.02 + + +@pytest.fixture +def world() -> World: + """The home holds the Kick and Snare recordings and a Forty folder of short takes.""" + files: List[HomeFile] = [ + recording(home_path(KICK), PLAYED_SECONDS), + recording(home_path(SNARE), PLAYED_SECONDS), + *(recording(home_path(FORTY) / take(index), SHORT_SECONDS) for index in range(FORTY_COUNT)), + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) diff --git a/tests/screens/main/list/constants.py b/tests/screens/main/list/constants.py new file mode 100644 index 000000000..12d164b6c --- /dev/null +++ b/tests/screens/main/list/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +FORTY: Final[str] = "Forty" +FORTY_COUNT: Final[int] = 40 +FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/main/list/steps.py b/tests/screens/main/list/steps.py new file mode 100644 index 000000000..9fc43535f --- /dev/null +++ b/tests/screens/main/list/steps.py @@ -0,0 +1,27 @@ +from functools import partial +from pathlib import Path + +from tests.screens.main.list.constants import FREQUENCY +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import explorer_row +from tests.suite.screens.worlds.home import HomeFile + + +def take(index: int) -> str: + """Returns the file name of the numbered take, such as ``take07.wav``.""" + return f"take{index:02d}.wav" + + +def recording(path: Path, seconds: float) -> HomeFile: + """Returns a seeded tone recording of ``seconds`` to be written at ``path``.""" + return Recording(destination=path, seconds=seconds, frequency=FREQUENCY) + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer and waits for its row and for the folder read to end.""" + converter = screen.main.converter + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(partial(converter.list.has_row, path), bool, description=f"{path.name} gathered") + screen.expect(lambda: not converter.scan_shown(), bool, description="the read done") diff --git a/tests/screens/main/list/test_folders.py b/tests/screens/main/list/test_folders.py new file mode 100644 index 000000000..73f295439 --- /dev/null +++ b/tests/screens/main/list/test_folders.py @@ -0,0 +1,158 @@ +import operator +from functools import partial +from typing import Final + +import dearpygui.dearpygui as dpg + +from tests.screens.main.list.constants import FORTY, FORTY_COUNT +from tests.screens.main.list.steps import gather, recording, take +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import read_scroll +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import choose_from_the_row_menu, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK + +STILL_FRAMES: Final[int] = 30 +SHOW_RECORDINGS: Final[str] = "main.converter.label.context_open_folder" +HIDE_RECORDINGS: Final[str] = "main.converter.label.context_close_folder" + + +class TestAFolderInTheList: + """A gathered folder arrives closed; its marker, a double-click on its name and its menu each open and + close it. + """ + + def test_each_gesture_opens_and_closes_it(self, screen: Screen) -> None: + """Each gesture opens the folder and the same gesture closes it again.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def arrives_closed(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + + def the_marker(screen: Screen) -> None: + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="open by its marker") + + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by its marker") + + def a_double_click_on_its_name(screen: Screen) -> None: + screen.hand.double_click(listing.row(folder)) + screen.expect(partial(listing.is_open, folder), bool, description="open by a double-click") + + screen.hand.double_click(listing.row(folder)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by a double-click") + + def its_menu(screen: Screen) -> None: + choose_from_the_row_menu(screen, folder, screen.words(SHOW_RECORDINGS)) + screen.expect(partial(listing.is_open, folder), bool, description="open from its menu") + + choose_from_the_row_menu(screen, folder, screen.words(HIDE_RECORDINGS)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed from its menu") + + screen.scenario(arrives_closed, the_marker, a_double_click_on_its_name, its_menu).run() + + +class TestAFolderThatScrolls: + """An open folder of forty scrolls in its own region, end to end, and its rows hold still under the + pointer. + + The folder opens beside a recording. The wheel takes it to the last row while the outer list keeps + its scroll. The pointer then rests on that row for thirty frames and its rectangle stays the same. + """ + + def test_it_scrolls_on_its_own_to_its_last_row(self, screen: Screen) -> None: + """The folder scrolls to its last row in its own region.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + region = listing.tags.region(str(folder)) + last = folder / take(FORTY_COUNT - 1) + + def open_it(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert screen.bridge.ask(lambda: read_scroll(region)).maximum > 0 + + def the_wheel_takes_it_to_its_last_row(screen: Screen) -> None: + well_before = screen.bridge.ask(lambda: read_scroll(listing.tags.well)) + + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + assert screen.bridge.ask(lambda: read_scroll(listing.tags.well)) == well_before + + def rows_hold_still_under_the_pointer(screen: Screen) -> None: + row = listing.row(last) + screen.hand.hover(row) + + with screen.record(lambda: read_item(row).rect) as recording: + screen.frames(STILL_FRAMES) + + assert len(set(recording.values())) == 1, recording.values() + + screen.scenario(open_it, the_wheel_takes_it_to_its_last_row, rows_hold_still_under_the_pointer).run() + + +class TestRemovingFolders: + """Removing a recording inside an open folder, or the folder itself, takes exactly what it names.""" + + def test_a_recording_inside_goes_alone_and_the_folder_counts_one_fewer(self, screen: Screen) -> None: + """Only the chosen recording goes; the folder stays open, counts one fewer and the other rows are + kept. + """ + listing = screen.main.converter.list + folder = home_path(FORTY) + removed = folder / take(1) + + def open_the_folder_beside_a_recording(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + def remove_one_inside(screen: Screen) -> None: + outside = screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home_path(KICK)))) + + listing.remove(removed) + + screen.expect(partial(listing.has_row, removed), operator.not_, description="the recording gone") + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=FORTY_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="the folder counting one fewer") + assert listing.has_row(folder / take(0)) and listing.has_row(folder / take(2)) + assert listing.is_open(folder) + assert screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home_path(KICK)))) == outside + + screen.scenario(open_the_folder_beside_a_recording, remove_one_inside).run() + + def test_an_open_folder_goes_with_its_recordings_and_comes_back_closed(self, screen: Screen) -> None: + """The folder leaves with its recordings, and gathering it again brings it back closed.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def remove_it_open(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.remove(folder) + + screen.expect(partial(listing.has_row, folder), operator.not_, description="the folder gone") + assert listing.rows() == [listing.row(home_path(KICK))] + + def gather_it_again(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(folder)] + + screen.scenario(remove_it_open, gather_it_again).run() diff --git a/tests/screens/main/list/test_look.py b/tests/screens/main/list/test_look.py new file mode 100644 index 000000000..513036eae --- /dev/null +++ b/tests/screens/main/list/test_look.py @@ -0,0 +1,75 @@ +from functools import partial +from typing import Dict, Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_TEXT, TAG_GLOBAL_THEME_STEMS_GROUP_ROW +from sampletones_application.tags.main import PRE_MAIN_CONVERTER_STEMS +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.list.constants import FORTY +from tests.screens.main.list.steps import gather +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK + +SAME_LINE_PIXELS: Final[float] = 1.0 +HEADING: Final[str] = "heading" + +CHANNEL_NAMES: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: "global.context.label.pulse_1", + ChannelName.PULSE2: "global.context.label.pulse_2", + ChannelName.TRIANGLE: "global.context.label.triangle", + ChannelName.NOISE: "global.context.label.noise", +} + + +class TestTheListsLook: + """The channel names above the list wear their channel's color; a folder row carries the band and has a + recording's height. + + A recording and a folder are gathered. The scenario reads the heading texts and themes, the band + theme of each row, the row heights and the line shared by a recording's name and its boxes. + """ + + def test_names_bands_and_lines(self, screen: Screen) -> None: + """Names, bands, heights and lines match what the list should look like.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def gather_a_recording_and_a_folder(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + + def the_channel_names_wear_their_colours(screen: Screen) -> None: + for channel, key in CHANNEL_NAMES.items(): + name = compose_tag(PRE_MAIN_CONVERTER_STEMS, HEADING, channel, SUF_TEXT) + + assert screen.bridge.ask(partial(dpg.get_value, name)) == screen.words(key) + assert screen.theme_of(name) == CHANNEL_THEME_TAGS[channel] + + def the_folder_alone_carries_the_band(screen: Screen) -> None: + assert listing.band_theme(folder) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW + assert listing.band_theme(home_path(KICK)) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def rows_share_a_height_and_names_share_a_line_with_their_boxes(screen: Screen) -> None: + folder_box = screen.bridge.ask(lambda: read_item(listing.row(folder)).rect) + recording_box = screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK))).rect) + check_box = screen.bridge.ask( + lambda: read_item(listing.channel_box(home_path(KICK), ChannelName.PULSE1)).rect + ) + assert folder_box is not None and recording_box is not None and check_box is not None + + assert folder_box.height == recording_box.height + assert ( + abs((recording_box.y + recording_box.height / 2) - (check_box.y + check_box.height / 2)) + <= SAME_LINE_PIXELS + ) + + screen.scenario( + gather_a_recording_and_a_folder, + the_channel_names_wear_their_colours, + the_folder_alone_carries_the_band, + rows_share_a_height_and_names_share_a_line_with_their_boxes, + ).run() diff --git a/tests/screens/main/list/test_rows.py b/tests/screens/main/list/test_rows.py new file mode 100644 index 000000000..cc480a1a3 --- /dev/null +++ b/tests/screens/main/list/test_rows.py @@ -0,0 +1,131 @@ +import operator +from functools import partial +from typing import Final + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.list.steps import gather +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.converter import CONVERT_ONE, CONVERT_SEVERAL +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +PLAY: Final[str] = "global.context.label.play" + + +class TestTheListComingAndGoing: + """With nothing gathered the hint shows alone; the first recording brings the list, and removing the + last one brings the hint back. + """ + + def test_the_hint_and_the_list_take_turns(self, screen: Screen) -> None: + """The hint and the list swap places as recordings arrive and leave.""" + listing = screen.main.converter.list + + def the_hint_alone(screen: Screen) -> None: + assert listing.hint_shown() + assert not listing.list_shown() + + def the_first_recording_brings_the_list(screen: Screen) -> None: + gather(screen, home_path(KICK)) + + assert listing.list_shown() + assert not listing.hint_shown() + + def removing_the_last_takes_it_away(screen: Screen) -> None: + listing.remove(home_path(KICK)) + + screen.expect(listing.hint_shown, bool, description="the hint back") + assert not listing.list_shown() + assert listing.rows() == [] + + screen.scenario(the_hint_alone, the_first_recording_brings_the_list, removing_the_last_takes_it_away).run() + + +class TestUntickingEveryChannel: + """A recording with every channel unticked reads as out of play and leaves the run count, and stays + listed. + """ + + def test_the_row_dims_and_the_run_counts_without_it(self, screen: Screen) -> None: + """The row takes the inert theme and the button counts one recording; ticking one channel restores + both. + """ + listing = screen.main.converter.list + converter = screen.main.converter + + def gather_two(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def untick_every_channel_of_one(screen: Screen) -> None: + for channel in ChannelName: + if listing.channel_ticked(home_path(KICK), channel): + listing.tick(home_path(KICK), channel) + screen.expect( + partial(listing.channel_ticked, home_path(KICK), channel), + operator.not_, + description=f"{channel} let go", + ) + + screen.expect( + partial(listing.row_theme, home_path(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW_INERT.__eq__, + description="the row out of play", + ) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(SNARE).stem) + assert listing.has_row(home_path(KICK)) + + def one_channel_brings_it_back(screen: Screen) -> None: + listing.tick(home_path(KICK), ChannelName.PULSE1) + + screen.expect( + partial(listing.row_theme, home_path(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW.__eq__, + description="the row in play", + ) + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + screen.scenario(gather_two, untick_every_channel_of_one, one_channel_brings_it_back).run() + + +class TestPlayingFromTheList: + """A recording in the list plays on a double-click, and its menu leads with Play while the file exists.""" + + def test_a_double_click_plays_and_play_greys_once_the_file_is_gone(self, screen: Screen) -> None: + """Play is the first menu entry and turns disabled after the file is deleted from the disk.""" + listing = screen.main.converter.list + menu = screen.context_menu + + def a_double_click_plays(screen: Screen) -> None: + gather(screen, home_path(KICK)) + + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(screen.sequencer.playback.can_stop, bool, description="the recording playing") + assert listing.rows() == [listing.row(home_path(KICK))] + + def its_menu_leads_with_play(screen: Screen) -> None: + screen.hand.right_click(listing.row(home_path(KICK))) + screen.expect(menu.is_shown, bool, description="the menu") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), True) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def play_greys_once_the_file_is_gone(screen: Screen) -> None: + home_path(KICK).unlink() + + screen.hand.right_click(listing.row(home_path(KICK))) + screen.expect(menu.is_shown, bool, description="the menu again") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), False) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away again") + + screen.scenario(a_double_click_plays, its_menu_leads_with_play, play_greys_once_the_file_is_gone).run() diff --git a/tests/screens/main/list/test_thousands.py b/tests/screens/main/list/test_thousands.py new file mode 100644 index 000000000..22d0ef577 --- /dev/null +++ b/tests/screens/main/list/test_thousands.py @@ -0,0 +1,124 @@ +from functools import partial +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.paths import LAYOUT_DIRECTORY +from sampletones_shared.utils.serialization import load_yaml_model +from tests.screens.main.list.constants import FREQUENCY +from tests.screens.main.list.steps import gather +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW, REMOVE_RECORDING +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +THOUSANDS: Final[str] = "Thousands" +THOUSANDS_COUNT: Final[int] = 1500 +TINY_SECONDS: Final[float] = 0.01 +ONE_ROW: Final[int] = 1 +STEMS_LAYOUT: Final[Path] = LAYOUT_DIRECTORY / "general" / "stems.yaml" +MENU_ROW: Final[int] = 3 +WHEEL_PAST: Final[int] = 5 + + +def thousand(index: int) -> str: + """Returns the file name of the numbered recording in the folder of thousands.""" + return f"take{index:04d}.wav" + + +def rows_inside(screen: Screen, folder: Path) -> List[str]: + """Returns the rows of ``folder`` that the list draws at this moment, in list order.""" + listing = screen.main.converter.list + inside = {listing.row(folder / thousand(index)) for index in range(THOUSANDS_COUNT)} + return [row for row in listing.rows() if row in inside] + + +class TestAFolderOfThousands: + """A folder of thousands opens drawing only the rows in view; its last row and any row menu still + answer. + + The folder is gathered and opened. One scenario scrolls to the last row. The other opens a row's + menu, turns the wheel, chooses Remove and expects that row alone to be gone. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds one folder of 1500 tiny recordings.""" + files = [ + Recording(destination=home_path(THOUSANDS) / thousand(index), seconds=TINY_SECONDS, frequency=FREQUENCY) + for index in range(THOUSANDS_COUNT) + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) + + def open_it(self, screen: Screen) -> None: + """Gathers the folder of thousands and opens it in the list.""" + listing = screen.main.converter.list + gather(screen, home_path(THOUSANDS)) + listing.toggle_folder(home_path(THOUSANDS)) + screen.expect(partial(listing.is_open, home_path(THOUSANDS)), bool, description="the folder open") + + def test_it_draws_the_rows_in_view_and_reaches_its_last(self, screen: Screen) -> None: + """The drawn rows fill the region plus the overscan, and scrolling to the end draws the last row.""" + listing = screen.main.converter.list + folder = home_path(THOUSANDS) + region = listing.tags.region(str(folder)) + last = folder / thousand(THOUSANDS_COUNT - 1) + + def opens_drawing_what_is_in_view(screen: Screen) -> None: + self.open_it(screen) + drawn = rows_inside(screen, folder) + region_box = screen.bridge.ask(lambda: read_item(region).rect) + first = screen.bridge.ask(lambda: read_item(drawn[0]).rect) + second = screen.bridge.ask(lambda: read_item(drawn[1]).rect) + assert region_box is not None and first is not None and second is not None + pitch = second.y - first.y + overscan = load_yaml_model(STEMS_LAYOUT, StemsListLayout).window_overscan + + assert len(drawn) <= int(region_box.height / pitch) + ONE_ROW + 2 * overscan + + def reaches_the_last_row(screen: Screen) -> None: + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(opens_drawing_what_is_in_view, reaches_the_last_row).run() + + def test_remove_chosen_after_the_wheel_turned_removes_the_row_the_menu_was_opened_on(self, screen: Screen) -> None: + """The menu stays open while the wheel turns, and Remove deletes the row it was opened on.""" + listing = screen.main.converter.list + menu = screen.context_menu + folder = home_path(THOUSANDS) + region = listing.tags.region(str(folder)) + target = folder / thousand(MENU_ROW) + + def open_the_menu_and_turn_the_wheel(screen: Screen) -> None: + self.open_it(screen) + screen.hand.scroll_into_view(listing.row(target)) + screen.hand.right_click(listing.row(target)) + screen.expect(menu.is_shown, bool, description="the row's menu") + + screen.hand.turn_wheel_over(region, WHEEL_PAST) + + assert menu.is_shown() + + def remove_from_the_menu(screen: Screen) -> None: + menu.choose(screen.words(REMOVE_RECORDING)) + + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=THOUSANDS_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="one row fewer") + + def that_row_alone_went(screen: Screen) -> None: + screen.hand.turn_wheel_over(region, -2 * WHEEL_PAST) + screen.expect(partial(listing.has_row, folder / thousand(0)), bool, description="the top in view") + + assert not listing.has_row(target) + assert listing.has_row(folder / thousand(MENU_ROW - 1)) and listing.has_row(folder / thousand(MENU_ROW + 1)) + + screen.scenario(open_the_menu_and_turn_the_wheel, remove_from_the_menu, that_row_alone_went).run() diff --git a/tests/screens/main/mix/__init__.py b/tests/screens/main/mix/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/test_mix.py b/tests/screens/main/mix/test_turning_to_a_mix.py similarity index 85% rename from tests/screens/main/test_mix.py rename to tests/screens/main/mix/test_turning_to_a_mix.py index 946f84604..154eee1a0 100644 --- a/tests/screens/main/test_mix.py +++ b/tests/screens/main/mix/test_turning_to_a_mix.py @@ -8,17 +8,18 @@ from sampletones_application.constants.conversion import MAX_STEM_SOURCES from sampletones_application.constants.output import OutputKind from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording +from tests.suite.screens.seeds.recordings import Recording from tests.suite.screens.steps.main import gather, home_path from tests.suite.screens.vocabulary.converter import REMOVE_RECORDING -from tests.suite.screens.world import World, lived_in_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world GATHERED: Final[int] = MAX_STEM_SOURCES + 2 SECONDS: Final[float] = 2.0 FREQUENCY: Final[float] = 220.0 NAMES_CLICKED: Final[int] = 4 - COUNT_LINE: Final[str] = "main.converter.template.stem_selection_limit" + MOVES: Final[List[str]] = [ "main.converter.label.context_move_up", "main.converter.label.context_move_down", @@ -29,27 +30,36 @@ def recording(index: int) -> Path: + """The home path of the gathered recording numbered ``index``.""" return home_path(f"voice{index:02d}.wav") def recordings(count: int) -> List[Path]: + """The home paths of the first ``count`` gathered recordings.""" return [recording(index) for index in range(count)] @pytest.fixture def world() -> World: + """A lived-in home holding two more recordings than a mix has room for.""" files = tuple(Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) for path in recordings(GATHERED)) return World(state=lived_in_world().state, application_config=None, config=None, files=files) def count_line(screen: Screen, picked: int, total: int) -> str: + """The line the mix question shows for ``picked`` recordings ticked out of ``total``.""" return screen.words(COUNT_LINE).format(picked=picked, total=total, room=MAX_STEM_SOURCES) class TestTurningToAMixPastItsRoom: - """Turning to a mix with more gathered than a mix holds asks which to take, with a full pick ticked.""" + """Turning to a mix with more recordings gathered than a mix holds asks which to take, with a full + pick ticked. + """ def test_the_question_ticks_a_full_pick_and_cancel_leaves_everything(self, screen: Screen) -> None: + """The question ticks the first recordings up to the room of a mix; Cancel keeps the list and the + per-recording switch, and turning to a mix again asks again. + """ main = screen.main question = main.converter.mix_question paths = recordings(GATHERED) @@ -82,6 +92,9 @@ def asking_again_asks_again(screen: Screen) -> None: screen.scenario(turn_to_a_mix, cancel_leaves_the_list_and_the_switch, asking_again_asks_again).run() def test_a_full_pick_swaps_one_for_one_and_add_waits_for_a_pick(self, screen: Screen) -> None: + """With a full pick, letting one go makes the next recording live for ticking; Add answers once at + least one recording is picked and then makes the mix. + """ main = screen.main question = main.converter.mix_question paths = recordings(GATHERED) @@ -128,6 +141,9 @@ def add_makes_the_mix(screen: Screen) -> None: ).run() def test_a_double_click_sounds_a_row_and_names_leave_no_highlight(self, screen: Screen) -> None: + """A double-click on a name plays that recording, and clicking names leaves the ticks and highlights + as they were. + """ main = screen.main question = main.converter.mix_question paths = recordings(GATHERED) @@ -157,7 +173,12 @@ def names_clicked_leave_no_highlight(screen: Screen) -> None: class TestAMixOfOneAndOfTwo: - """A mix of one draws no level band and offers no moves; a second recording brings the bands; one per recording takes them away.""" + """A mix of one draws no level band and offers no moves; a second recording brings the bands, and + one per recording takes them away. + + One recording is gathered and the switch turned to a mix. Its right-click menu lists Remove and no + moves. A second recording brings the level bands; turning back to one per recording removes them. + """ def test_bands_come_with_the_second_recording_and_go_with_one_per_recording(self, screen: Screen) -> None: main = screen.main diff --git a/tests/screens/main/row_settings/__init__.py b/tests/screens/main/row_settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/row_settings/conftest.py b/tests/screens/main/row_settings/conftest.py new file mode 100644 index 000000000..8f1ebe39a --- /dev/null +++ b/tests/screens/main/row_settings/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.screens.main.row_settings.steps import recordings +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + + +@pytest.fixture +def world() -> World: + """A lived-in home holding the kick and snare recordings and a folder of two takes.""" + return World(state=lived_in_world().state, application_config=None, config=None, files=recordings()) diff --git a/tests/screens/main/row_settings/constants.py b/tests/screens/main/row_settings/constants.py new file mode 100644 index 000000000..ea4422b59 --- /dev/null +++ b/tests/screens/main/row_settings/constants.py @@ -0,0 +1,6 @@ +from typing import Final, Tuple + +PAIR: Final[str] = "Pair" +PAIR_TAKES: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +PLAYED_SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/main/row_settings/steps.py b/tests/screens/main/row_settings/steps.py new file mode 100644 index 000000000..cf548c9fa --- /dev/null +++ b/tests/screens/main/row_settings/steps.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import Dict, Tuple + +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import FREQUENCY, PAIR, PAIR_TAKES, PLAYED_SECONDS +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import HomeFile + + +def recordings() -> Tuple[HomeFile, ...]: + """The recordings the home holds: kick, snare and the two takes in the Pair folder.""" + return ( + Recording(destination=home_path(KICK), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + Recording(destination=home_path(SNARE), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + *( + Recording(destination=home_path(PAIR) / name, seconds=PLAYED_SECONDS, frequency=FREQUENCY) + for name in PAIR_TAKES + ), + ) + + +def ticked(screen: Screen, path: Path) -> Dict[ChannelName, bool]: + """Reads which channel boxes the list shows ticked on the row at ``path``.""" + return {channel: screen.main.converter.list.channel_ticked(path, channel) for channel in ChannelName} diff --git a/tests/screens/main/row_settings/test_boxes.py b/tests/screens/main/row_settings/test_boxes.py new file mode 100644 index 000000000..7cbfc9b97 --- /dev/null +++ b/tests/screens/main/row_settings/test_boxes.py @@ -0,0 +1,93 @@ +import operator +from functools import partial + +import pytest + +from sampletones_application.ui.themes.channels import PARTIAL_CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import PAIR, PAIR_TAKES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW + + +class TestAFoldersBoxes: + """A folder's box reads partial where its recordings disagree; one click on it settles them all and the + next click lets them go. + + The folder of two takes is gathered and opened. The scenario clicks Pulse 1 on one take, then on the + folder, then on the folder again, and expects the folder's box to turn partial, then every take to + hold the box, then every take to let it go. + """ + + def test_a_box_inside_turns_the_folders_partial_and_the_folders_settles_them(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first, second = (folder / name for name in PAIR_TAKES) + channel = ChannelName.PULSE1 + + def open_the_folder(screen: Screen) -> None: + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def a_box_inside_changes_that_row_alone(screen: Screen) -> None: + listing.tick(first, channel) + + screen.expect(partial(listing.channel_ticked, first, channel), operator.not_, description="the box let go") + assert listing.channel_ticked(second, channel) + screen.expect( + partial(listing.channel_theme, folder, channel), + PARTIAL_CHANNEL_THEME_TAGS[channel].__eq__, + description="the folder's box partial", + ) + assert not listing.channel_ticked(folder, channel) + + def the_folders_box_settles_them_all(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), bool, description="the folder's box held") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def the_next_click_lets_them_go(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), operator.not_, description="let go") + assert not listing.channel_ticked(first, channel) and not listing.channel_ticked(second, channel) + + screen.scenario( + open_the_folder, + a_box_inside_changes_that_row_alone, + the_folders_box_settles_them_all, + the_next_click_lets_them_go, + ).run() + + +class TestTheCardFollowsABoxClicked: + """Ticking a box inside an open folder brings the card to that row, and the folder's own box brings it + to the folder. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a box clicked in the list leaves Source settings where it stood", + ) + def test_the_card_names_the_row_whose_box_was_clicked(self, screen: Screen) -> None: + """Source settings names the take whose box was clicked, then the folder once its box is clicked.""" + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first = folder / PAIR_TAKES[0] + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.tick(first, ChannelName.PULSE1) + + screen.expect(main.source.subject, first.stem.__eq__, description="the card on the row clicked") + listing.tick(folder, ChannelName.PULSE1) + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the card on the folder") diff --git a/tests/screens/main/row_settings/test_during_a_run.py b/tests/screens/main/row_settings/test_during_a_run.py new file mode 100644 index 000000000..4e2989711 --- /dev/null +++ b/tests/screens/main/row_settings/test_during_a_run.py @@ -0,0 +1,93 @@ +import operator +from functools import partial +from typing import Dict, Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.steps import recordings, ticked +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import choose_from_the_row_menu, gather, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, REMOVE_RECORDING +from tests.suite.screens.vocabulary.recordings import KICK, SNARE +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import converting_world + +HELD_TIMEOUT_SECONDS: Final[float] = 60.0 +PROGRESS: Final[str] = "main.converter.template.progress_template" + + +class TestTheListDuringARun: + """While a run converts the list, Del, the menu's Remove and the channel keys leave the list as it is; + after the run they reach the rows again. + + A kick and a snare recording are gathered and a held run starts. Del and the Pulse 2 key then act on + the picked kick, whose row is disabled and whose boxes stay as they were. Once the run ends and its + question is closed, the menu's Remove takes the snare away and Del takes the kick away. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding the recordings, set up to convert them.""" + return converting_world(recordings()) + + def test_the_list_stands_as_it_was_through_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """The rows and their boxes stay the same through the run, and removal works again after it.""" + converter = screen.main.converter + listing = converter.list + before: List[Dict[ChannelName, bool]] = [] + + def start_a_held_run(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + + converter.press_action() + + started = screen.words(PROGRESS).format(0, 2) + screen.bridge.expect( + converter.status, + lambda status: status.startswith(started), + description="the run under way on its first recording", + timeout=HELD_TIMEOUT_SECONDS, + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def the_keys_and_the_menu_reach_nothing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + assert not screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK)))).enabled + assert not screen.context_menu.is_shown() + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(home_path(SNARE))] + assert ticked(screen, home_path(KICK)) == before[0] + + def after_the_run_the_menu_and_del_reach_the_row(screen: Screen) -> None: + conversion_hold.release() + screen.bridge.expect( + converter.end_prompt.is_shown, + bool, + description="the run's end", + timeout=HELD_TIMEOUT_SECONDS, + ) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end prompt closed") + assert ticked(screen, home_path(KICK)) == before[0] + + choose_from_the_row_menu(screen, home_path(SNARE), screen.words(REMOVE_RECORDING)) + screen.expect( + partial(listing.has_row, home_path(SNARE)), operator.not_, description="removed from the menu" + ) + listing.pick(home_path(KICK)) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(KICK)), operator.not_, description="removed by Del") + + screen.scenario( + start_a_held_run, + the_keys_and_the_menu_reach_nothing, + after_the_run_the_menu_and_del_reach_the_row, + ).run() diff --git a/tests/screens/main/row_settings/test_hundreds.py b/tests/screens/main/row_settings/test_hundreds.py new file mode 100644 index 000000000..2453fbb61 --- /dev/null +++ b/tests/screens/main/row_settings/test_hundreds.py @@ -0,0 +1,72 @@ +import operator +from functools import partial +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from tests.screens.main.row_settings.constants import FREQUENCY +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +HUNDREDS: Final[str] = "Hundreds" +HUNDREDS_COUNT: Final[int] = 300 +TINY_SECONDS: Final[float] = 0.01 + + +class TestHundredsGathered: + """Some three hundred recordings gathered: the card collapses and expands, the interface answers + meanwhile, and the list scrolls end to end. + + The folder is gathered and the card collapsed. The Sequencer and the Main tab are brought to the + front in turn. The card is then expanded, the folder opened and the list scrolled to its end, where + the last row is drawn and hovered. + """ + + @pytest.fixture + def world(self) -> World: + """A lived-in home holding a folder of three hundred very short recordings.""" + files = tuple( + Recording( + destination=home_path(HUNDREDS) / f"take{index:03d}.wav", seconds=TINY_SECONDS, frequency=FREQUENCY + ) + for index in range(HUNDREDS_COUNT) + ) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + def test_collapse_expand_and_scroll_end_to_end(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + folder = home_path(HUNDREDS) + last = folder / f"take{HUNDREDS_COUNT - 1:03d}.wav" + + def gather_and_collapse(screen: Screen) -> None: + gather(screen, folder) + card.toggle() + + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + def the_interface_answers_meanwhile(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + + def expand_and_scroll_end_to_end(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + screen.hand.scroll_to_end(listing.tags.region(str(folder))) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(gather_and_collapse, the_interface_answers_meanwhile, expand_and_scroll_end_to_end).run() diff --git a/tests/screens/main/row_settings/test_keys.py b/tests/screens/main/row_settings/test_keys.py new file mode 100644 index 000000000..76a9280df --- /dev/null +++ b/tests/screens/main/row_settings/test_keys.py @@ -0,0 +1,106 @@ +import operator +from functools import partial +from typing import Dict, Final, List + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_INPUT_SEARCH +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.steps import ticked +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +FILTER_TEXT: Final[str] = "zz" + + +class TestAChannelKey: + """A channel's key settles that channel on the picked row alone; with nothing picked it changes nothing. + + Two recordings are gathered and Pulse 2 is pressed with nothing picked. The kick is then picked and + the key pressed again; the kick's Pulse 2 flips once and the snare keeps its boxes. + """ + + def test_it_reaches_the_picked_row_alone(self, screen: Screen) -> None: + listing = screen.main.converter.list + before: List[Dict[ChannelName, bool]] = [] + + def press_with_nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + before.extend((ticked(screen, home_path(KICK)), ticked(screen, home_path(SNARE)))) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + def press_with_a_row_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped once") + assert ticked(screen, home_path(SNARE)) == before[1] + + screen.scenario(press_with_nothing_picked, press_with_a_row_picked).run() + + +class TestTheKeysOfTheList: + """Del and the channel keys reach the picked row while the Converter card is open and no field holds + the keys. + """ + + def test_a_collapsed_card_keeps_del_from_the_list(self, screen: Screen) -> None: + """Del leaves both rows in place while the card is collapsed and removes the picked row once open.""" + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + + def del_with_the_card_collapsed(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + card.toggle() + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def del_with_the_card_open(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.pick(home_path(SNARE)) + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(del_with_the_card_collapsed, del_with_the_card_open).run() + + def test_a_focused_field_takes_del_and_the_channel_keys(self, screen: Screen) -> None: + """Keys typed into the explorer's filter field stay in the field; once the field lets go, the same key + reaches the picked row. + """ + main = screen.main + listing = main.converter.list + field = compose_tag(TAG_MAIN_EXPLORER_PANEL, SUF_INPUT_SEARCH) + before: List[Dict[ChannelName, bool]] = [] + + def keys_into_the_field(screen: Screen) -> None: + gather(screen, home_path(KICK)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + screen.hand.click(field) + screen.hand.type_text(FILTER_TEXT) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def the_same_key_reaches_the_row_once_the_field_lets_go(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(keys_into_the_field, the_same_key_reaches_the_row_once_the_field_lets_go).run() diff --git a/tests/screens/main/row_settings/test_pick.py b/tests/screens/main/row_settings/test_pick.py new file mode 100644 index 000000000..18667a43b --- /dev/null +++ b/tests/screens/main/row_settings/test_pick.py @@ -0,0 +1,117 @@ +import operator +from functools import partial +from typing import Final + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import PAIR, PAIR_TAKES +from tests.screens.main.row_settings.steps import ticked +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import FOLDER_ROW +from tests.suite.screens.vocabulary.recordings import KICK, SNARE + +NEW_RECORDINGS: Final[str] = "main.source.label.new_recordings" + + +class TestTheCardFollowsThePick: + """Source settings names the picked row, recording or folder, and reads New recordings while no row is + picked. + """ + + def test_it_names_each_pick_in_turn(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + + def nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(PAIR)) + + assert main.source.subject() == screen.words(NEW_RECORDINGS) + + def a_recording_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.expect(main.source.subject, home_path(KICK).stem.__eq__, description="the recording named") + assert listing.is_picked(home_path(KICK)) + assert {channel: main.source.channel_ticked(channel) for channel in ChannelName} == ticked( + screen, home_path(KICK) + ) + + def a_folder_picked(screen: Screen) -> None: + listing.pick(home_path(PAIR)) + + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the folder named") + assert not listing.is_picked(home_path(KICK)) + + screen.scenario(nothing_picked, a_recording_picked, a_folder_picked).run() + + +class TestThePickHoldsThrough: + """The pick stands through a second click, a double-click that plays the row, and Esc stopping it. + + The kick is picked twice, then double-clicked so that it plays, then Esc stops playback. After each + gesture the kick is still the picked row and the card still names it. + """ + + def test_clicks_double_clicks_and_esc_keep_the_pick(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + playback = screen.sequencer.playback + + def pick_twice(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + screen.expect(partial(listing.is_picked, home_path(KICK)), bool, description="picked") + + listing.pick(home_path(KICK)) + + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + def a_double_click_plays_and_keeps_it(screen: Screen) -> None: + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(playback.can_stop, bool, description="the recording playing") + assert listing.is_picked(home_path(KICK)) + + def esc_stops_and_keeps_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.can_stop, operator.not_, description="playback stopped") + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + screen.scenario(pick_twice, a_double_click_plays_and_keeps_it, esc_stops_and_keeps_it).run() + + +class TestARightClickMovesThePick: + """A right-click on a row picks it, and Del then removes that row. + + The kick is picked and the snare right-clicked. The card names the snare and the menu is put away; + Del then removes the snare and leaves the kick. + """ + + def test_del_after_a_right_click_removes_the_row_clicked(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + + def right_click_another_row(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + + screen.hand.right_click(listing.row(home_path(SNARE))) + + screen.expect(menu.is_shown, bool, description="the menu") + screen.expect(main.source.subject, home_path(SNARE).stem.__eq__, description="the card on the row clicked") + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def del_removes_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(right_click_another_row, del_removes_it).run() diff --git a/tests/screens/main/run/__init__.py b/tests/screens/main/run/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/run/conftest.py b/tests/screens/main/run/conftest.py new file mode 100644 index 000000000..e5e792563 --- /dev/null +++ b/tests/screens/main/run/conftest.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Final + +import pytest + +from tests.screens.main.run.constants import ALBUM, ALBUM_TAKES, BASS, DISC, DISC_TAKES, DRUMS, LEAD +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import converting_world + +SECONDS: Final[float] = 0.3 +FREQUENCY: Final[float] = 220.0 + + +def recording(path: Path) -> HomeFile: + """A short recording to be written at ``path``.""" + return Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) + + +def run_world() -> World: + """A home holding three recordings and an Album folder with a nested Disc2 folder, set up to convert.""" + paths = [ + home_path(BASS), + home_path(LEAD), + home_path(DRUMS), + *(home_path(ALBUM) / name for name in ALBUM_TAKES), + *(home_path(ALBUM) / DISC / name for name in DISC_TAKES), + ] + return converting_world(tuple(recording(path) for path in paths)) + + +@pytest.fixture +def world() -> World: + """The world of ``run_world``.""" + return run_world() diff --git a/tests/screens/main/run/constants.py b/tests/screens/main/run/constants.py new file mode 100644 index 000000000..a550b5c33 --- /dev/null +++ b/tests/screens/main/run/constants.py @@ -0,0 +1,11 @@ +from typing import Final, Tuple + +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DRUMS: Final[str] = "drums.wav" +ALBUM: Final[str] = "Album" +DISC: Final[str] = "Disc2" +ALBUM_TAKES: Final[Tuple[str, ...]] = ("one.wav", "two.wav") +DISC_TAKES: Final[Tuple[str, ...]] = ("three.wav",) +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" diff --git a/tests/screens/main/run/steps.py b/tests/screens/main/run/steps.py new file mode 100644 index 000000000..983f014cf --- /dev/null +++ b/tests/screens/main/run/steps.py @@ -0,0 +1,31 @@ +import operator +from pathlib import Path +from typing import Dict, List + +from tests.screens.main.run.constants import RECONSTRUCTION_SUFFIX, RUN_TIMEOUT_SECONDS +from tests.suite.screens.screen import Screen + + +def run_to_its_end(screen: Screen) -> None: + """Presses the button naming the run and waits for the question its end asks, which it closes.""" + converter = screen.main.converter + converter.press_action() + wait_for_the_end(screen) + + +def wait_for_the_end(screen: Screen) -> None: + """Waits for the question the run's end asks and closes it.""" + converter = screen.main.converter + screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end closed") + + +def written(destination: Path) -> List[Path]: + """The reconstruction files below ``destination``, sorted.""" + return sorted(destination.rglob(f"*{RECONSTRUCTION_SUFFIX}")) + + +def modified(paths: List[Path]) -> Dict[Path, int]: + """The modification time of each of ``paths``, in nanoseconds.""" + return {path: path.stat().st_mtime_ns for path in paths} diff --git a/tests/screens/main/run/test_button_and_lines.py b/tests/screens/main/run/test_button_and_lines.py new file mode 100644 index 000000000..aabb96993 --- /dev/null +++ b/tests/screens/main/run/test_button_and_lines.py @@ -0,0 +1,106 @@ +from typing import Final + +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_GROUP_INPUT, TAG_MAIN_CONVERTER_GROUP_ORDER +from tests.screens.main.run.constants import BASS, LEAD, RUN_TIMEOUT_SECONDS +from tests.screens.main.run.steps import wait_for_the_end +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import CONVERT_NOTHING, CONVERT_ONE, CONVERT_SEVERAL + +MIX_SEVERAL: Final[str] = "main.converter.template.mix_recordings" + + +class TestTheButtonNamesTheRun: + """The button under the list names the run each time the list or the switch changes. + + One recording is gathered, then a second, then the switch turned to a mix, then both removed. The + button reads, in turn, Convert one, Convert several, the mix, and Convert nothing. + """ + + def test_one_several_a_mix_and_none(self, screen: Screen) -> None: + main = screen.main + converter = main.converter + + def one(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(BASS).stem) + + def several(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def a_mix(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(converter.action, screen.words(MIX_SEVERAL).format(count=2).__eq__, description="a mix named") + + def none(screen: Screen) -> None: + converter.list.remove(home_path(BASS)) + converter.list.remove(home_path(LEAD)) + + screen.expect(converter.action, screen.words(CONVERT_NOTHING).__eq__, description="nothing to name") + + screen.scenario(one, several, a_mix, none).run() + + +class TestTheLinesOfTheCard: + """Order joins from a mix's second recording, Destination always stands, and Input shows only while + a run reads. + """ + + def test_order_and_destination(self, screen: Screen) -> None: + """A mix of one shows no Order line, the second recording brings it; Destination always shows.""" + main = screen.main + converter = main.converter + + def order_waits_for_a_second_recording_in_a_mix(screen: Screen) -> None: + assert converter.destination() + gather(screen, home_path(BASS)) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown + + def the_second_brings_it(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown, + bool, + description="Order joining", + ) + assert converter.destination() + + screen.scenario(order_waits_for_a_second_recording_in_a_mix, the_second_brings_it).run() + + def test_input_stands_while_a_run_reads(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """The Input line is hidden before a held run, shown while it reads and hidden again after its end.""" + converter = screen.main.converter + + def no_input_before_the_run(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + def input_while_it_reads(screen: Screen) -> None: + converter.press_action() + + screen.bridge.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown, + bool, + description="the Input line", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def no_input_after_it(screen: Screen) -> None: + conversion_hold.release() + wait_for_the_end(screen) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + screen.scenario(no_input_before_the_run, input_while_it_reads, no_input_after_it).run() diff --git a/tests/screens/main/run/test_destination.py b/tests/screens/main/run/test_destination.py new file mode 100644 index 000000000..647837e3c --- /dev/null +++ b/tests/screens/main/run/test_destination.py @@ -0,0 +1,99 @@ +from pathlib import Path +from typing import Dict, FrozenSet, List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.main.run.constants import ALBUM, ALBUM_TAKES, BASS, DISC, DISC_TAKES, LEAD, RECONSTRUCTION_SUFFIX +from tests.screens.main.run.steps import modified, run_to_its_end, written +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, give_each_its_own_channel, home_path + + +def converted_channels(path: Path) -> FrozenSet[ChannelName]: + """The channels a written reconstruction approximates, read off the file.""" + return frozenset(Reconstruction.load(path).approximations) + + +def written_as(path: Path) -> Path: + """The one reconstruction a run wrote for the recording at ``path``, wherever below the folder it went.""" + found = [file for file in written(RECONSTRUCTIONS_DIRECTORY) if file.stem == path.stem] + assert len(found) == 1, found + return found[0] + + +class TestWhereARunWrites: + """A run writes each recording with the channels its row gave, where the Destination line says.""" + + def channels(self) -> Dict[Path, ChannelName]: + """The channel each gathered recording is given: the bass Triangle, the lead Pulse 1.""" + return {home_path(BASS): ChannelName.TRIANGLE, home_path(LEAD): ChannelName.PULSE1} + + def test_each_recording_carries_the_channels_its_row_gave(self, screen: Screen) -> None: + """Each written reconstruction approximates only the channel its recording was given.""" + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + + run_to_its_end(screen) + + for path, channel in self.channels().items(): + assert converted_channels(written_as(path)) == {channel} + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: Destination names no folder a run of differing channels writes into", + ) + def test_the_destination_line_names_the_folder_the_run_writes_into(self, screen: Screen) -> None: + """Every reconstruction is written directly in the folder the Destination line names.""" + converter = screen.main.converter + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + destination = Path(converter.destination()) + + run_to_its_end(screen) + + assert all(written_as(path).parent == destination for path in self.channels()) + + +class TestAFolderConverts: + """A listed folder converts into a tree mirroring it; a rerun replaces nothing already written and + makes the rest. + + The Album folder, with its nested Disc2 folder, is gathered and converted. One written file is then + deleted and the run repeated; the deleted file returns and the others keep their modification times. + """ + + def test_a_mirrored_tree_then_only_what_is_missing(self, screen: Screen) -> None: + converter = screen.main.converter + folder = home_path(ALBUM) + expected = [Path(name).with_suffix(RECONSTRUCTION_SUFFIX) for name in ALBUM_TAKES] + [ + (Path(DISC) / name).with_suffix(RECONSTRUCTION_SUFFIX) for name in DISC_TAKES + ] + kept: List[Dict[Path, int]] = [] + destinations: List[Path] = [] + + def convert_the_folder(screen: Screen) -> None: + gather(screen, folder) + destinations.append(Path(converter.destination())) + + run_to_its_end(screen) + + files = written(destinations[0]) + assert len(files) == len(expected) + assert all(any(file.as_posix().endswith(part.as_posix()) for file in files) for part in expected) + + def rerun_makes_only_what_is_missing(screen: Screen) -> None: + files = written(destinations[0]) + missing, *standing = files + missing.unlink() + kept.append(modified(standing)) + + run_to_its_end(screen) + + assert written(destinations[0]) == files + assert modified(standing) == kept[0] + + screen.scenario(convert_the_folder, rerun_makes_only_what_is_missing).run() diff --git a/tests/screens/main/run/test_progress_and_failure.py b/tests/screens/main/run/test_progress_and_failure.py new file mode 100644 index 000000000..ce45d7b95 --- /dev/null +++ b/tests/screens/main/run/test_progress_and_failure.py @@ -0,0 +1,96 @@ +import multiprocessing +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from tests.screens.main.run.constants import BASS, RUN_TIMEOUT_SECONDS +from tests.screens.main.run.steps import written +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import gather, home_path +from tests.suite.screens.vocabulary.converter import CANCEL_RUN, CONVERT_ONE + +RUN_FAILED: Final[str] = "main.converter.message.status_error" + + +class TestAHeldRun: + """A run shows its progress, Continue lets it run on, and Stop ends it with nothing written and every + worker gone. + + A held run starts on one recording and the button turns to its cancel label. Pressing it asks about + stopping; Cancel on that question keeps the run going. Pressing again and confirming ends the run, + restores the Convert label and leaves no worker process and no written file. + """ + + @pytest.mark.usefixtures("conversion_hold") + def test_progress_continue_and_stop(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.cancel_prompt + destinations: List[Path] = [] + + def start_it(screen: Screen) -> None: + gather(screen, home_path(BASS)) + destinations.append(Path(converter.destination())) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def continue_lets_it_run(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about stopping") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert converter.action() == screen.words(CANCEL_RUN) + + def stop_ends_it(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.bridge.expect( + lambda: len(multiprocessing.active_children()), + operator.not_, + description="no worker left", + timeout=RUN_TIMEOUT_SECONDS, + ) + assert written(destinations[0]) == [] + + screen.scenario(start_it, continue_lets_it_run, stop_ends_it).run() + + +class TestARecordingGoneBeforeTheRun: + """A recording deleted after it was gathered is reported in words naming it when the run reaches it.""" + + def test_the_run_says_what_went_wrong(self, screen: Screen) -> None: + """An error notice opens with the run-failed words and the file's name, and the status line reads the + run-failed words. + """ + converter = screen.main.converter + notice = screen.error_notice + gather(screen, home_path(BASS)) + home_path(BASS).unlink() + + converter.press_action() + + screen.bridge.expect(notice.is_shown, bool, description="the failure reported", timeout=RUN_TIMEOUT_SECONDS) + words = notice.words() + screen.claim_error(home_path(BASS).name) + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the report dismissed") + assert words.startswith(screen.words(RUN_FAILED)) + assert home_path(BASS).name in words + assert converter.status() == screen.words(RUN_FAILED) diff --git a/tests/screens/main/run/test_replacing.py b/tests/screens/main/run/test_replacing.py new file mode 100644 index 000000000..b7a721d75 --- /dev/null +++ b/tests/screens/main/run/test_replacing.py @@ -0,0 +1,112 @@ +import operator +import shutil +from pathlib import Path +from typing import Dict, Final, List + +from tests.screens.main.run.constants import BASS, DRUMS, LEAD, RECONSTRUCTION_SUFFIX +from tests.screens.main.run.steps import modified, run_to_its_end, wait_for_the_end, written +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, gather, home_path + +OTHER_RECONSTRUCTION: Final[str] = "other.stn" +RECONSTRUCT_FILE: Final[str] = "main.explorer.label.context_reconstruct_file" + + +class TestReplacingWhatARunWrote: + """Recordings gathered by name and converted before ask first, and Convert anyway replaces exactly + them. + + Three recordings are converted and a fourth reconstruction is copied beside them. Running again asks + about replacing; Cancel keeps every file as it was, and Convert anyway rewrites the three while the + fourth keeps its modification time. + """ + + def test_cancel_writes_nothing_and_convert_anyway_replaces_those_alone(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.overwrite_prompt + paths = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] + state: Dict[str, Dict[Path, int]] = {} + destinations: List[Path] = [] + + def convert_three_and_set_another_beside_them(screen: Screen) -> None: + gather(screen, *paths) + destinations.append(Path(converter.destination())) + run_to_its_end(screen) + first = written(destinations[0])[0] + shutil.copyfile(first, destinations[0] / OTHER_RECONSTRUCTION) + state["before"] = modified(written(destinations[0])) + + def cancel_writes_nothing(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about replacing") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert modified(written(destinations[0])) == state["before"] + + def convert_anyway_replaces_those_alone(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + after = modified(written(destinations[0])) + other = destinations[0] / OTHER_RECONSTRUCTION + assert after[other] == state["before"][other] + assert all( + after[file] != state["before"][file] + for file in (destinations[0] / f"{path.stem}{RECONSTRUCTION_SUFFIX}" for path in paths) + ) + + screen.scenario( + convert_three_and_set_another_beside_them, + cancel_writes_nothing, + convert_anyway_replaces_those_alone, + ).run() + + +class TestReconstructingAFileWhileAListStands: + """Reconstruct file with a list gathered asks first: Keep the list converts nothing, Replace it + converts the file. + + Two recordings are gathered and Reconstruct file chosen from the explorer's menu on a third. + Cancel keeps the list and writes nothing. Choosing it again and confirming runs the third alone. + """ + + def test_keep_the_list_then_replace_it(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.replace_prompt + destinations: List[Path] = [] + + def ask_from_the_browser(screen: Screen) -> None: + gather(screen, home_path(BASS), home_path(LEAD)) + destinations.append(Path(converter.destination())) + screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) + + screen.expect(prompt.is_shown, bool, description="the question about the list") + + def keep_the_list(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert converter.list.rows() == [converter.list.row(home_path(BASS)), converter.list.row(home_path(LEAD))] + assert written(destinations[0]) == [] + + def replace_it(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) + screen.expect(screen.context_menu.is_shown, bool, description="the menu again") + screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + assert [file.stem for file in written(destinations[0])] == [home_path(DRUMS).stem] + + screen.scenario(ask_from_the_browser, keep_the_list, replace_it).run() diff --git a/tests/screens/main/scan/__init__.py b/tests/screens/main/scan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/test_scan.py b/tests/screens/main/scan/test_reading_a_folder.py similarity index 68% rename from tests/screens/main/test_scan.py rename to tests/screens/main/scan/test_reading_a_folder.py index 4c4766f64..b3ec61f30 100644 --- a/tests/screens/main/test_scan.py +++ b/tests/screens/main/scan/test_reading_a_folder.py @@ -5,12 +5,15 @@ import pytest from sampletones_application.categories.hierarchy import Tab -from tests.suite.screens.dearpygui.items import Item -from tests.suite.screens.holds import Holds, ReleaseSignal, ScanHold +from tests.suite.screens.holds.base import Holds +from tests.suite.screens.holds.scan import ScanHold +from tests.suite.screens.holds.signal import ReleaseSignal from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.main import explorer_row, home_path from tests.suite.screens.vocabulary.converter import FOLDER_ROW, SCAN_PROGRESS -from tests.suite.screens.world import HomeFile, World, lived_in_world +from tests.suite.screens.worlds.home import HomeFile, World +from tests.suite.screens.worlds.recordings import lived_in_world MANY: Final[str] = "Many" FEW: Final[str] = "Few" @@ -21,23 +24,20 @@ FREQUENCY: Final[float] = 330.0 SLOW_ENTRY_SECONDS: Final[float] = 2.0 SLOW_RELEASE_FILE: Final[str] = "release-slow-scan" - RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" -def home(name: str) -> Path: - return Path.cwd() / name - - def folder_of(name: str, count: int) -> List[HomeFile]: + """The recordings of a folder named ``name`` holding ``count`` short takes.""" return [ - Recording(destination=home(name) / f"take{index:03d}.wav", seconds=SECONDS, frequency=FREQUENCY) + Recording(destination=home_path(name) / f"take{index:03d}.wav", seconds=SECONDS, frequency=FREQUENCY) for index in range(count) ] @pytest.fixture def world() -> World: + """A lived-in home holding a folder of many recordings and a folder of two.""" lived_in = lived_in_world() return World( state=lived_in.state, @@ -47,22 +47,25 @@ def world() -> World: ) -def explorer_row(screen: Screen, path: Path) -> Item: - return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") - - def folder_label(screen: Screen, name: str, count: int) -> str: + """The text of a folder's row in the list, naming the folder and its recordings.""" return screen.words(FOLDER_ROW).format(name=name, count=count) class TestReadingALargeFolder: - """A folder being read shows a window naming it and counting what it found, while the rest answers.""" + """A folder being read shows a window naming it and counting what it found, while the rest of the + interface answers. + + The large folder is Ctrl-clicked and a held read begins. The window reports the count so far, the + Sequencer and Main tabs come to the front in turn, and releasing the read closes the window and + gathers the folder as one row. + """ def test_the_window_counts_and_the_interface_answers_meanwhile(self, screen: Screen, scan_hold: ScanHold) -> None: converter = screen.main.converter def start_reading(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) expected = screen.words(SCAN_PROGRESS).format(count=REPORTED_COUNT, name=MANY) screen.expect(converter.scan_words, expected.__eq__, description="the count of the folder read so far") @@ -81,23 +84,27 @@ def the_read_lands(screen: Screen) -> None: screen.expect(converter.scan_shown, operator.not_, description="the window closed") screen.expect( - lambda: converter.list.has_row(home(MANY)), + lambda: converter.list.has_row(home_path(MANY)), bool, description="the folder gathered", ) - assert converter.list.label(home(MANY)) == folder_label(screen, MANY, MANY_RECORDINGS) + assert converter.list.label(home_path(MANY)) == folder_label(screen, MANY, MANY_RECORDINGS) screen.scenario(start_reading, the_interface_answers, the_read_lands).run() class TestStoppingARead: - """Stop gives the read up and gathers nothing; the next Ctrl-click reads afresh.""" + """Stop gives the read up and gathers nothing; the next Ctrl-click reads afresh. + + The large folder is read and stopped, then the small folder is Ctrl-clicked and released. The list + ends with the small folder's row alone. + """ def test_stop_gathers_nothing_and_the_next_read_runs(self, screen: Screen, scan_hold: ScanHold) -> None: converter = screen.main.converter def stop_a_read(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) screen.expect(converter.scan_shown, bool, description="the read under way") converter.stop_scan() @@ -105,18 +112,25 @@ def stop_a_read(screen: Screen) -> None: screen.expect(converter.scan_shown, operator.not_, description="the window closed") def the_next_read_runs(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(FEW))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) screen.expect(converter.scan_shown, bool, description="a new read under way") scan_hold.release() - screen.expect(lambda: converter.list.has_row(home(FEW)), bool, description="the second folder gathered") - assert converter.list.rows() == [converter.list.row(home(FEW))] + screen.expect( + lambda: converter.list.has_row(home_path(FEW)), bool, description="the second folder gathered" + ) + assert converter.list.rows() == [converter.list.row(home_path(FEW))] screen.scenario(stop_a_read, the_next_read_runs).run() class TestAskingForAFolderDuringARead: - """A folder asked for while another is being read is turned away, and asking again once the window closes reads it.""" + """A folder asked for while another is being read waits; asking again once the window closes reads it. + + The large folder is read and the small folder's Reconstruct menu entry chosen meanwhile. The read + under way goes on and the list gains only the large folder. Ctrl-clicking the small folder after + the window closes gathers it. + """ def test_the_read_under_way_runs_on_and_the_folder_asked_for_reads_afterwards( self, @@ -126,10 +140,10 @@ def test_the_read_under_way_runs_on_and_the_folder_asked_for_reads_afterwards( converter = screen.main.converter def ask_for_a_second_folder_during_a_read(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) screen.expect(converter.scan_shown, bool, description="the read under way") - screen.explorer.right_click(explorer_row(screen, home(FEW))) + screen.explorer.right_click(explorer_row(screen, home_path(FEW))) screen.expect(screen.context_menu.is_shown, bool, description="the second folder's menu") screen.context_menu.choose(screen.words(RECONSTRUCT_DIRECTORY)) @@ -140,15 +154,19 @@ def ask_for_a_second_folder_during_a_read(screen: Screen) -> None: def the_first_read_lands(screen: Screen) -> None: scan_hold.release() - screen.expect(lambda: converter.list.has_row(home(MANY)), bool, description="the first folder gathered") + screen.expect( + lambda: converter.list.has_row(home_path(MANY)), bool, description="the first folder gathered" + ) assert not converter.run_shown() def asking_again_reads_it(screen: Screen) -> None: screen.expect(converter.scan_shown, operator.not_, description="the window closed") - screen.explorer.ctrl_click(explorer_row(screen, home(FEW))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) - screen.expect(lambda: converter.list.has_row(home(FEW)), bool, description="the second folder gathered") + screen.expect( + lambda: converter.list.has_row(home_path(FEW)), bool, description="the second folder gathered" + ) screen.scenario( ask_for_a_second_folder_during_a_read, @@ -162,6 +180,7 @@ class TestAskingForAFolderAsAStoppedReadWindsDown: @pytest.fixture def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> ScanHold: + """A scan hold that releases slowly, so a stopped read still winds down when the next folder comes.""" hold = ScanHold(ReleaseSignal(Path.cwd().parent / SLOW_RELEASE_FILE), interval=SLOW_ENTRY_SECONDS) hold.install(monkeypatch) screen_holds.add(hold) @@ -173,13 +192,14 @@ def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> Sca reason="bugs-and-todos ยง Bugs: a folder asked for while a stopped read winds down is dropped", ) def test_the_folder_asked_for_is_read(self, screen: Screen, scan_hold: ScanHold) -> None: + """The small folder, Ctrl-clicked just after Stop, is gathered once the hold is released.""" converter = screen.main.converter - screen.explorer.ctrl_click(explorer_row(screen, home(MANY))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) screen.expect(converter.scan_shown, bool, description="the read under way") converter.stop_scan() screen.expect(converter.scan_shown, operator.not_, description="the window closed") - screen.explorer.ctrl_click(explorer_row(screen, home(FEW))) + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) scan_hold.release() - screen.expect(lambda: converter.list.has_row(home(FEW)), bool, description="the folder asked for gathered") + screen.expect(lambda: converter.list.has_row(home_path(FEW)), bool, description="the folder asked for gathered") diff --git a/tests/screens/main/test_gathering.py b/tests/screens/main/test_gathering.py deleted file mode 100644 index 369ed92df..000000000 --- a/tests/screens/main/test_gathering.py +++ /dev/null @@ -1,330 +0,0 @@ -import operator -from functools import partial -from pathlib import Path -from typing import Final, List, Tuple - -import pytest - -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_COLLAPSE_STRIP, - TAG_GLOBAL_THEME_COLLAPSE_HEADER, - TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED, -) -from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_SOURCE_PANEL -from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import Item -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording, WrittenBytes -from tests.suite.screens.vocabulary.converter import CANCEL_RUN, CONVERT_NOTHING, CONVERT_ONE, FOLDER_ROW -from tests.suite.screens.vocabulary.recordings import KICK, SNARE -from tests.suite.screens.world import HomeFile, World, lived_in_world - -TAKES: Final[str] = "Takes" -INNER: Final[str] = "Inner" -LOOPS: Final[str] = "Loops" -NOTES: Final[str] = "Notes" -TAKES_AT_THE_TOP: Final[Tuple[str, ...]] = ("one.wav", "two.wav", "three.wav") -TAKES_INSIDE: Final[Tuple[str, ...]] = ("four.wav", "five.wav") -LOOPS_HELD: Final[Tuple[str, ...]] = ("left.wav", "right.wav") -COLLIDING: Final[Tuple[str, ...]] = ("Kick Loop.wav", "kick loop.wav", "ลผรณล‚w.wav") -PLAYED_SECONDS: Final[float] = 2.0 -SHORT_SECONDS: Final[float] = 0.2 -TONE_FREQUENCY: Final[float] = 220.0 -NOTE_TEXT: Final[bytes] = b"A folder holding words, not sounds.\n" -ROUNDS: Final[int] = 20 -FIRST_COUNTED_ROUND: Final[int] = 2 -POINTER_REACH: Final[int] = 4 - -ADD_AS_STEM: Final[str] = "main.explorer.label.context_add_stem" -ADD_FOLDER: Final[str] = "main.explorer.label.context_add_folder_stems" -NOTHING_BELOW: Final[str] = "main.converter.message.scan_nothing_below" - - -def home(name: str) -> Path: - """A path in the home the application was started in.""" - return Path.cwd() / name - - -def recording(path: Path, seconds: float) -> HomeFile: - return Recording(destination=path, seconds=seconds, frequency=TONE_FREQUENCY) - - -def gathering_world() -> World: - files: List[HomeFile] = [ - recording(home(KICK), PLAYED_SECONDS), - recording(home(SNARE), PLAYED_SECONDS), - *(recording(home(TAKES) / name, SHORT_SECONDS) for name in TAKES_AT_THE_TOP), - *(recording(home(TAKES) / INNER / name, SHORT_SECONDS) for name in TAKES_INSIDE), - *(recording(home(LOOPS) / name, SHORT_SECONDS) for name in LOOPS_HELD), - *(recording(home(name), SHORT_SECONDS) for name in COLLIDING), - WrittenBytes(home(NOTES) / INNER / "readme.txt", NOTE_TEXT), - ] - world = lived_in_world() - return World( - state=world.state, - application_config=world.application_config, - config=world.config, - files=tuple(files), - ) - - -def explorer_row(screen: Screen, path: Path) -> Item: - return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") - - -def gathered(screen: Screen, *paths: Path) -> None: - """Waits until the list holds a row for each of ``paths`` and nothing else.""" - converter = screen.main.converter - expected = sorted(converter.list.row(path) for path in paths) - screen.expect( - lambda: sorted(converter.list.rows()), - expected.__eq__, - description=f"the list holding {[path.name for path in paths]} alone", - ) - - -def folder_label(screen: Screen, path: Path, count: int) -> str: - return screen.words(FOLDER_ROW).format(name=path.name, count=count) - - -def wait_for_the_scan_to_end(screen: Screen) -> None: - screen.expect(lambda: not screen.main.converter.scan_shown(), bool, description="the folder read") - - -@pytest.fixture -def world() -> World: - return gathering_world() - - -class TestNavigatingGathersNothing: - """Opening folders in the explorer gathers nothing; a double-click on a recording gathers that one.""" - - def test_the_list_stays_empty_until_a_recording_is_double_clicked(self, screen: Screen) -> None: - converter = screen.main.converter - - def open_folders(screen: Screen) -> None: - for folder in (home(TAKES), home(TAKES) / INNER, home(LOOPS)): - row = explorer_row(screen, folder) - - screen.explorer.open_by_click(row) - - screen.expect(partial(screen.explorer.is_open, row), bool, description=f"{folder.name} open") - - def the_list_stays_empty(screen: Screen) -> None: - assert converter.list.hint_shown() - assert not converter.list.list_shown() - assert converter.action() == screen.words(CONVERT_NOTHING) - - def a_double_click_gathers_that_recording(screen: Screen) -> None: - screen.explorer.double_click(explorer_row(screen, home(KICK))) - - gathered(screen, home(KICK)) - assert converter.action() == screen.words(CONVERT_ONE).format(name=home(KICK).stem) - assert not converter.list.hint_shown() - - screen.scenario(open_folders, the_list_stays_empty, a_double_click_gathers_that_recording).run() - - -class TestAPlainClickOnARecording: - """A plain click plays a recording and gathers nothing; Ctrl-click and Add as stem gather it.""" - - def test_it_plays_and_the_gathering_gestures_gather(self, screen: Screen) -> None: - converter = screen.main.converter - playback = screen.sequencer.playback - - def a_click_plays(screen: Screen) -> None: - screen.explorer.click(explorer_row(screen, home(KICK))) - - screen.expect(playback.can_stop, bool, description="the recording playing") - assert converter.list.hint_shown() - - def ctrl_click_gathers(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(KICK))) - - gathered(screen, home(KICK)) - - def add_as_stem_gathers(screen: Screen) -> None: - screen.explorer.right_click(explorer_row(screen, home(SNARE))) - screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") - - screen.context_menu.choose(screen.words(ADD_AS_STEM)) - - gathered(screen, home(KICK), home(SNARE)) - assert not screen.context_menu.is_shown() - - screen.scenario(a_click_plays, ctrl_click_gathers, add_as_stem_gathers).run() - - -class TestGatheringAFolder: - """A folder lands as one row counting its recordings, subfolders included, and starts no run.""" - - def test_ctrl_click_and_add_folder_each_give_one_counted_row(self, screen: Screen) -> None: - converter = screen.main.converter - - def gather_a_recording_first(screen: Screen) -> None: - screen.explorer.double_click(explorer_row(screen, home(KICK))) - - gathered(screen, home(KICK)) - - def ctrl_click_a_folder(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(TAKES))) - - gathered(screen, home(KICK), home(TAKES)) - wait_for_the_scan_to_end(screen) - count = len(TAKES_AT_THE_TOP) + len(TAKES_INSIDE) - assert converter.list.label(home(TAKES)) == folder_label(screen, home(TAKES), count) - - def add_folder_from_its_menu(screen: Screen) -> None: - screen.explorer.right_click(explorer_row(screen, home(LOOPS))) - screen.expect(screen.context_menu.is_shown, bool, description="the folder's menu") - - screen.context_menu.choose(screen.words(ADD_FOLDER)) - - gathered(screen, home(KICK), home(TAKES), home(LOOPS)) - wait_for_the_scan_to_end(screen) - assert converter.list.label(home(LOOPS)) == folder_label(screen, home(LOOPS), len(LOOPS_HELD)) - - def no_run_started_and_the_recording_stands_first(screen: Screen) -> None: - assert not converter.run_shown() - assert converter.action() != screen.words(CANCEL_RUN) - assert converter.list.rows()[0] == converter.list.row(home(KICK)) - - screen.scenario( - gather_a_recording_first, - ctrl_click_a_folder, - add_folder_from_its_menu, - no_run_started_and_the_recording_stands_first, - ).run() - - -class TestGatheringAtTheEdges: - """Gathering holds at its edges: a folder twice, a folder of no recordings, names that collide.""" - - def test_a_folder_gathered_twice_stands_once(self, screen: Screen) -> None: - for _ in range(2): - screen.explorer.ctrl_click(explorer_row(screen, home(TAKES))) - wait_for_the_scan_to_end(screen) - - gathered(screen, home(TAKES)) - - def test_a_folder_of_no_recordings_says_so_and_gathers_nothing(self, screen: Screen) -> None: - converter = screen.main.converter - notice = converter.nothing_below_notice - - def ctrl_click_a_folder_of_words(screen: Screen) -> None: - screen.explorer.ctrl_click(explorer_row(screen, home(NOTES))) - - screen.expect(notice.is_shown, bool, description="the notice") - assert notice.words() == screen.words(NOTHING_BELOW) - - def dismiss_and_gather_a_folder_of_sounds(screen: Screen) -> None: - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice gone") - - screen.explorer.ctrl_click(explorer_row(screen, home(LOOPS))) - - gathered(screen, home(LOOPS)) - - screen.scenario(ctrl_click_a_folder_of_words, dismiss_and_gather_a_folder_of_sounds).run() - - def test_names_colliding_in_case_and_spacing_stand_apart(self, screen: Screen) -> None: - converter = screen.main.converter - paths = [home(name) for name in COLLIDING] - - def gather_each(screen: Screen) -> None: - for path in paths: - screen.explorer.ctrl_click(explorer_row(screen, path)) - - gathered(screen, *paths) - - def a_box_on_one_leaves_the_others(screen: Screen) -> None: - first, *others = paths - before = [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] - - converter.list.tick(first, ChannelName.PULSE1) - - screen.expect( - lambda: converter.list.channel_ticked(first, ChannelName.PULSE1), - (not before[0]).__eq__, - description="the box flipped on the first", - ) - assert [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] == before - - screen.scenario(gather_each, a_box_on_one_leaves_the_others).run() - - -class TestRightClickingOverAndOver: - """Twenty right-clicks across the browser and the list each open a menu at the pointer, and build nothing lasting.""" - - def test_each_menu_stands_at_the_pointer_and_the_interface_does_not_grow(self, screen: Screen) -> None: - converter = screen.main.converter - menu = screen.context_menu - counts: List[int] = [] - - def gather(screen: Screen) -> None: - screen.explorer.double_click(explorer_row(screen, home(KICK))) - gathered(screen, home(KICK)) - - def right_click_over_and_over(screen: Screen) -> None: - for round_number in range(1, ROUNDS + 1): - in_the_list = round_number % 2 == 0 - row = converter.list.row(home(KICK)) if in_the_list else explorer_row(screen, home(SNARE)) - if in_the_list: - screen.hand.scroll_into_view(row) - screen.hand.right_click(row) - else: - screen.explorer.right_click(row) - screen.expect(menu.is_shown, bool, description=f"the menu of round {round_number}") - pointer = screen.pointer() - - box = menu.box() - entries = menu.entries() - menu.dismiss() - - screen.expect(menu.is_shown, operator.not_, description=f"the menu of round {round_number} put away") - assert ( - box.overlap( - Rect( - x=pointer.x - POINTER_REACH, - y=pointer.y - POINTER_REACH, - width=2 * POINTER_REACH, - height=2 * POINTER_REACH, - ) - ) - is not None - ), (box, pointer) - assert entries and all(entry.enabled or entry.label for entry in entries) - if round_number >= FIRST_COUNTED_ROUND: - counts.append(screen.item_count()) - - def nothing_grew(screen: Screen) -> None: - assert counts[-1] == counts[0], counts - - screen.scenario(gather, right_click_over_and_over, nothing_grew).run() - - -class TestACardHeaderUnderThePointer: - """A card's header bar lights while the pointer rests on it and settles back once it leaves.""" - - def test_the_bar_lights_and_settles(self, screen: Screen) -> None: - strip = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_COLLAPSE_STRIP) - elsewhere = compose_tag(TAG_MAIN_SOURCE_PANEL, SUF_COLLAPSE_STRIP) - - def rest_on_the_bar(screen: Screen) -> None: - screen.hand.hover(strip) - - screen.expect( - lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED.__eq__, description="lit" - ) - - def move_off(screen: Screen) -> None: - screen.hand.scroll_into_view(elsewhere) - screen.hand.hover(elsewhere) - - screen.expect( - lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER.__eq__, description="settled" - ) - - screen.scenario(rest_on_the_bar, move_off).run() diff --git a/tests/screens/main/test_list.py b/tests/screens/main/test_list.py deleted file mode 100644 index 24ff9c098..000000000 --- a/tests/screens/main/test_list.py +++ /dev/null @@ -1,462 +0,0 @@ -import operator -from functools import partial -from pathlib import Path -from typing import Dict, Final, List - -import dearpygui.dearpygui as dpg -import pytest - -from sampletones_application.layout.general.stems import StemsListLayout -from sampletones_application.paths import LAYOUT_DIRECTORY -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_TEXT, - TAG_GLOBAL_THEME_STEMS_GROUP_ROW, - TAG_GLOBAL_THEME_STEMS_ROW, - TAG_GLOBAL_THEME_STEMS_ROW_INERT, -) -from sampletones_application.tags.main import PRE_MAIN_CONVERTER_STEMS -from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS -from sampletones_core.constants.enums import ChannelName -from sampletones_shared.utils.serialization import load_yaml_model -from tests.suite.screens.dearpygui.items import Item, read_item, read_scroll -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording -from tests.suite.screens.vocabulary.converter import CONVERT_ONE, CONVERT_SEVERAL, FOLDER_ROW, REMOVE_RECORDING -from tests.suite.screens.vocabulary.recordings import KICK, SNARE -from tests.suite.screens.world import HomeFile, World, lived_in_world - -FORTY: Final[str] = "Forty" -FORTY_COUNT: Final[int] = 40 -PLAYED_SECONDS: Final[float] = 2.0 -SHORT_SECONDS: Final[float] = 0.02 -FREQUENCY: Final[float] = 220.0 -STILL_FRAMES: Final[int] = 30 -SAME_LINE_PIXELS: Final[float] = 1.0 -HEADING: Final[str] = "heading" - -SHOW_RECORDINGS: Final[str] = "main.converter.label.context_open_folder" -HIDE_RECORDINGS: Final[str] = "main.converter.label.context_close_folder" -PLAY: Final[str] = "global.context.label.play" -CHANNEL_NAMES: Final[Dict[ChannelName, str]] = { - ChannelName.PULSE1: "global.context.label.pulse_1", - ChannelName.PULSE2: "global.context.label.pulse_2", - ChannelName.TRIANGLE: "global.context.label.triangle", - ChannelName.NOISE: "global.context.label.noise", -} - - -def home(name: str) -> Path: - return Path.cwd() / name - - -def take(index: int) -> str: - return f"take{index:02d}.wav" - - -def recording(path: Path, seconds: float) -> HomeFile: - return Recording(destination=path, seconds=seconds, frequency=FREQUENCY) - - -@pytest.fixture -def world() -> World: - files: List[HomeFile] = [ - recording(home(KICK), PLAYED_SECONDS), - recording(home(SNARE), PLAYED_SECONDS), - *(recording(home(FORTY) / take(index), SHORT_SECONDS) for index in range(FORTY_COUNT)), - ] - return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) - - -def explorer_row(screen: Screen, path: Path) -> Item: - return screen.expect_item(lambda: screen.explorer.file_row(path), description=f"the explorer's row of {path.name}") - - -def gather(screen: Screen, *paths: Path) -> None: - """Ctrl-clicks each of ``paths`` in the explorer and waits for its row.""" - converter = screen.main.converter - for path in paths: - screen.explorer.ctrl_click(explorer_row(screen, path)) - screen.expect(partial(converter.list.has_row, path), bool, description=f"{path.name} gathered") - screen.expect(lambda: not converter.scan_shown(), bool, description="the read done") - - -def choose_from_the_row_menu(screen: Screen, path: Path, entry: str) -> None: - row = screen.main.converter.list.row(path) - screen.hand.scroll_into_view(row) - screen.hand.right_click(row) - screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") - screen.context_menu.choose(entry) - - -class TestAFolderInTheList: - """A gathered folder arrives closed; its marker, a double-click on its name and its menu each open and close it.""" - - def test_each_gesture_opens_and_closes_it(self, screen: Screen) -> None: - listing = screen.main.converter.list - folder = home(FORTY) - - def arrives_closed(screen: Screen) -> None: - gather(screen, folder) - - assert not listing.is_open(folder) - - def the_marker(screen: Screen) -> None: - listing.toggle_folder(folder) - screen.expect(partial(listing.is_open, folder), bool, description="open by its marker") - - listing.toggle_folder(folder) - - screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by its marker") - - def a_double_click_on_its_name(screen: Screen) -> None: - screen.hand.double_click(listing.row(folder)) - screen.expect(partial(listing.is_open, folder), bool, description="open by a double-click") - - screen.hand.double_click(listing.row(folder)) - - screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by a double-click") - - def its_menu(screen: Screen) -> None: - choose_from_the_row_menu(screen, folder, screen.words(SHOW_RECORDINGS)) - screen.expect(partial(listing.is_open, folder), bool, description="open from its menu") - - choose_from_the_row_menu(screen, folder, screen.words(HIDE_RECORDINGS)) - - screen.expect(partial(listing.is_open, folder), operator.not_, description="closed from its menu") - - screen.scenario(arrives_closed, the_marker, a_double_click_on_its_name, its_menu).run() - - -class TestAFolderThatScrolls: - """An open folder of forty scrolls in its own region, end to end, and its rows hold still under the pointer.""" - - def test_it_scrolls_on_its_own_to_its_last_row(self, screen: Screen) -> None: - listing = screen.main.converter.list - folder = home(FORTY) - region = listing.tags.region(str(folder)) - last = folder / take(FORTY_COUNT - 1) - - def open_it(screen: Screen) -> None: - gather(screen, home(KICK), folder) - listing.toggle_folder(folder) - - screen.expect(partial(listing.is_open, folder), bool, description="the folder open") - assert screen.bridge.ask(lambda: read_scroll(region)).maximum > 0 - - def the_wheel_takes_it_to_its_last_row(screen: Screen) -> None: - well_before = screen.bridge.ask(lambda: read_scroll(listing.tags.well)) - - screen.hand.scroll_to_end(region) - - screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") - screen.hand.scroll_into_view(listing.row(last)) - screen.hand.hover(listing.row(last)) - assert screen.bridge.ask(lambda: read_scroll(listing.tags.well)) == well_before - - def rows_hold_still_under_the_pointer(screen: Screen) -> None: - row = listing.row(last) - screen.hand.hover(row) - - with screen.record(lambda: read_item(row).rect) as recording: - screen.frames(STILL_FRAMES) - - assert len(set(recording.values())) == 1, recording.values() - - screen.scenario(open_it, the_wheel_takes_it_to_its_last_row, rows_hold_still_under_the_pointer).run() - - -class TestTheListComingAndGoing: - """With nothing gathered the hint stands alone; the first recording brings the list and removing the last takes it away.""" - - def test_the_hint_and_the_list_take_turns(self, screen: Screen) -> None: - listing = screen.main.converter.list - - def the_hint_alone(screen: Screen) -> None: - assert listing.hint_shown() - assert not listing.list_shown() - - def the_first_recording_brings_the_list(screen: Screen) -> None: - gather(screen, home(KICK)) - - assert listing.list_shown() - assert not listing.hint_shown() - - def removing_the_last_takes_it_away(screen: Screen) -> None: - listing.remove(home(KICK)) - - screen.expect(listing.hint_shown, bool, description="the hint back") - assert not listing.list_shown() - assert listing.rows() == [] - - screen.scenario(the_hint_alone, the_first_recording_brings_the_list, removing_the_last_takes_it_away).run() - - -class TestRemovingFolders: - """Removing a recording inside an open folder or the folder itself takes what it names and nothing else.""" - - def test_a_recording_inside_goes_alone_and_the_folder_counts_one_fewer(self, screen: Screen) -> None: - listing = screen.main.converter.list - folder = home(FORTY) - removed = folder / take(1) - - def open_the_folder_beside_a_recording(screen: Screen) -> None: - gather(screen, home(KICK), folder) - listing.toggle_folder(folder) - screen.expect(partial(listing.is_open, folder), bool, description="the folder open") - - def remove_one_inside(screen: Screen) -> None: - outside = screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home(KICK)))) - - listing.remove(removed) - - screen.expect(partial(listing.has_row, removed), operator.not_, description="the recording gone") - expected = screen.words(FOLDER_ROW).format(name=folder.name, count=FORTY_COUNT - 1) - screen.expect(partial(listing.label, folder), expected.__eq__, description="the folder counting one fewer") - assert listing.has_row(folder / take(0)) and listing.has_row(folder / take(2)) - assert listing.is_open(folder) - assert screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home(KICK)))) == outside - - screen.scenario(open_the_folder_beside_a_recording, remove_one_inside).run() - - def test_an_open_folder_goes_with_its_recordings_and_comes_back_closed(self, screen: Screen) -> None: - listing = screen.main.converter.list - folder = home(FORTY) - - def remove_it_open(screen: Screen) -> None: - gather(screen, home(KICK), folder) - listing.toggle_folder(folder) - screen.expect(partial(listing.is_open, folder), bool, description="the folder open") - - listing.remove(folder) - - screen.expect(partial(listing.has_row, folder), operator.not_, description="the folder gone") - assert listing.rows() == [listing.row(home(KICK))] - - def gather_it_again(screen: Screen) -> None: - gather(screen, folder) - - assert not listing.is_open(folder) - assert listing.rows() == [listing.row(home(KICK)), listing.row(folder)] - - screen.scenario(remove_it_open, gather_it_again).run() - - -class TestUntickingEveryChannel: - """A recording with no channel left reads as out of play and leaves the run, while it stays listed.""" - - def test_the_row_dims_and_the_run_counts_without_it(self, screen: Screen) -> None: - listing = screen.main.converter.list - converter = screen.main.converter - - def gather_two(screen: Screen) -> None: - gather(screen, home(KICK), home(SNARE)) - - assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) - - def untick_every_channel_of_one(screen: Screen) -> None: - for channel in ChannelName: - if listing.channel_ticked(home(KICK), channel): - listing.tick(home(KICK), channel) - screen.expect( - partial(listing.channel_ticked, home(KICK), channel), - operator.not_, - description=f"{channel} let go", - ) - - screen.expect( - partial(listing.row_theme, home(KICK)), - TAG_GLOBAL_THEME_STEMS_ROW_INERT.__eq__, - description="the row out of play", - ) - assert converter.action() == screen.words(CONVERT_ONE).format(name=home(SNARE).stem) - assert listing.has_row(home(KICK)) - - def one_channel_brings_it_back(screen: Screen) -> None: - listing.tick(home(KICK), ChannelName.PULSE1) - - screen.expect( - partial(listing.row_theme, home(KICK)), - TAG_GLOBAL_THEME_STEMS_ROW.__eq__, - description="the row in play", - ) - assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) - - screen.scenario(gather_two, untick_every_channel_of_one, one_channel_brings_it_back).run() - - -class TestPlayingFromTheList: - """A recording in the list plays at a double-click, and its menu leads with Play while its file stands.""" - - def test_a_double_click_plays_and_play_greys_once_the_file_is_gone(self, screen: Screen) -> None: - listing = screen.main.converter.list - menu = screen.context_menu - - def a_double_click_plays(screen: Screen) -> None: - gather(screen, home(KICK)) - - screen.hand.double_click(listing.row(home(KICK))) - - screen.expect(screen.sequencer.playback.can_stop, bool, description="the recording playing") - assert listing.rows() == [listing.row(home(KICK))] - - def its_menu_leads_with_play(screen: Screen) -> None: - screen.hand.right_click(listing.row(home(KICK))) - screen.expect(menu.is_shown, bool, description="the menu") - - first = menu.entries()[0] - - assert (first.label, first.enabled) == (screen.words(PLAY), True) - menu.dismiss() - screen.expect(menu.is_shown, operator.not_, description="the menu put away") - - def play_greys_once_the_file_is_gone(screen: Screen) -> None: - home(KICK).unlink() - - screen.hand.right_click(listing.row(home(KICK))) - screen.expect(menu.is_shown, bool, description="the menu again") - - first = menu.entries()[0] - - assert (first.label, first.enabled) == (screen.words(PLAY), False) - menu.dismiss() - screen.expect(menu.is_shown, operator.not_, description="the menu put away again") - - screen.scenario(a_double_click_plays, its_menu_leads_with_play, play_greys_once_the_file_is_gone).run() - - -class TestTheListsLook: - """The channel names above the list wear their channel's colour; a folder row carries the band and matches a recording's height.""" - - def test_names_bands_and_lines(self, screen: Screen) -> None: - listing = screen.main.converter.list - folder = home(FORTY) - - def gather_a_recording_and_a_folder(screen: Screen) -> None: - gather(screen, home(KICK), folder) - - def the_channel_names_wear_their_colours(screen: Screen) -> None: - for channel, key in CHANNEL_NAMES.items(): - name = compose_tag(PRE_MAIN_CONVERTER_STEMS, HEADING, channel, SUF_TEXT) - - assert screen.bridge.ask(partial(dpg.get_value, name)) == screen.words(key) - assert screen.theme_of(name) == CHANNEL_THEME_TAGS[channel] - - def the_folder_alone_carries_the_band(screen: Screen) -> None: - assert listing.band_theme(folder) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW - assert listing.band_theme(home(KICK)) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW - - def rows_share_a_height_and_names_share_a_line_with_their_boxes(screen: Screen) -> None: - folder_box = screen.bridge.ask(lambda: read_item(listing.row(folder)).rect) - recording_box = screen.bridge.ask(lambda: read_item(listing.row(home(KICK))).rect) - check_box = screen.bridge.ask(lambda: read_item(listing.channel_box(home(KICK), ChannelName.PULSE1)).rect) - assert folder_box is not None and recording_box is not None and check_box is not None - - assert folder_box.height == recording_box.height - assert ( - abs((recording_box.y + recording_box.height / 2) - (check_box.y + check_box.height / 2)) - <= SAME_LINE_PIXELS - ) - - screen.scenario( - gather_a_recording_and_a_folder, - the_channel_names_wear_their_colours, - the_folder_alone_carries_the_band, - rows_share_a_height_and_names_share_a_line_with_their_boxes, - ).run() - - -THOUSANDS: Final[str] = "Thousands" -THOUSANDS_COUNT: Final[int] = 1500 -TINY_SECONDS: Final[float] = 0.01 -ONE_ROW: Final[int] = 1 -STEMS_LAYOUT: Final[Path] = LAYOUT_DIRECTORY / "general" / "stems.yaml" -MENU_ROW: Final[int] = 3 -WHEEL_PAST: Final[int] = 5 - - -def thousand(index: int) -> str: - return f"take{index:04d}.wav" - - -def rows_inside(screen: Screen, folder: Path) -> List[str]: - listing = screen.main.converter.list - inside = {listing.row(folder / thousand(index)) for index in range(THOUSANDS_COUNT)} - return [row for row in listing.rows() if row in inside] - - -class TestAFolderOfThousands: - """A folder of thousands opens drawing only the rows in view, and its last row and any menu still answer.""" - - @pytest.fixture - def world(self) -> World: - files = [ - Recording(destination=home(THOUSANDS) / thousand(index), seconds=TINY_SECONDS, frequency=FREQUENCY) - for index in range(THOUSANDS_COUNT) - ] - return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) - - def open_it(self, screen: Screen) -> None: - listing = screen.main.converter.list - gather(screen, home(THOUSANDS)) - listing.toggle_folder(home(THOUSANDS)) - screen.expect(partial(listing.is_open, home(THOUSANDS)), bool, description="the folder open") - - def test_it_draws_the_rows_in_view_and_reaches_its_last(self, screen: Screen) -> None: - listing = screen.main.converter.list - folder = home(THOUSANDS) - region = listing.tags.region(str(folder)) - last = folder / thousand(THOUSANDS_COUNT - 1) - - def opens_drawing_what_is_in_view(screen: Screen) -> None: - self.open_it(screen) - drawn = rows_inside(screen, folder) - region_box = screen.bridge.ask(lambda: read_item(region).rect) - first = screen.bridge.ask(lambda: read_item(drawn[0]).rect) - second = screen.bridge.ask(lambda: read_item(drawn[1]).rect) - assert region_box is not None and first is not None and second is not None - pitch = second.y - first.y - overscan = load_yaml_model(STEMS_LAYOUT, StemsListLayout).window_overscan - - assert len(drawn) <= int(region_box.height / pitch) + ONE_ROW + 2 * overscan - - def reaches_the_last_row(screen: Screen) -> None: - screen.hand.scroll_to_end(region) - - screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") - screen.hand.scroll_into_view(listing.row(last)) - screen.hand.hover(listing.row(last)) - - screen.scenario(opens_drawing_what_is_in_view, reaches_the_last_row).run() - - def test_remove_chosen_after_the_wheel_turned_removes_the_row_the_menu_was_opened_on(self, screen: Screen) -> None: - listing = screen.main.converter.list - menu = screen.context_menu - folder = home(THOUSANDS) - region = listing.tags.region(str(folder)) - target = folder / thousand(MENU_ROW) - - def open_the_menu_and_turn_the_wheel(screen: Screen) -> None: - self.open_it(screen) - screen.hand.scroll_into_view(listing.row(target)) - screen.hand.right_click(listing.row(target)) - screen.expect(menu.is_shown, bool, description="the row's menu") - - screen.hand.turn_wheel_over(region, WHEEL_PAST) - - assert menu.is_shown() - - def remove_from_the_menu(screen: Screen) -> None: - menu.choose(screen.words(REMOVE_RECORDING)) - - expected = screen.words(FOLDER_ROW).format(name=folder.name, count=THOUSANDS_COUNT - 1) - screen.expect(partial(listing.label, folder), expected.__eq__, description="one row fewer") - - def that_row_alone_went(screen: Screen) -> None: - screen.hand.turn_wheel_over(region, -2 * WHEEL_PAST) - screen.expect(partial(listing.has_row, folder / thousand(0)), bool, description="the top in view") - - assert not listing.has_row(target) - assert listing.has_row(folder / thousand(MENU_ROW - 1)) and listing.has_row(folder / thousand(MENU_ROW + 1)) - - screen.scenario(open_the_menu_and_turn_the_wheel, remove_from_the_menu, that_row_alone_went).run() diff --git a/tests/screens/main/test_row_settings.py b/tests/screens/main/test_row_settings.py deleted file mode 100644 index 1e1c5dc72..000000000 --- a/tests/screens/main/test_row_settings.py +++ /dev/null @@ -1,416 +0,0 @@ -import operator -from functools import partial -from pathlib import Path -from typing import Dict, Final, List, Tuple - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_INPUT_SEARCH -from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_PANEL -from sampletones_application.ui.themes.channels import PARTIAL_CHANNEL_THEME_TAGS -from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId -from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.dearpygui.items import read_item -from tests.suite.screens.holds import ConversionHold -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording -from tests.suite.screens.steps.main import choose_from_the_row_menu, gather, home_path -from tests.suite.screens.vocabulary.converter import CANCEL_RUN, FOLDER_ROW, REMOVE_RECORDING -from tests.suite.screens.vocabulary.recordings import KICK, SNARE -from tests.suite.screens.world import HomeFile, World, converting_world, lived_in_world - -PAIR: Final[str] = "Pair" -PAIR_TAKES: Final[Tuple[str, ...]] = ("left.wav", "right.wav") -PLAYED_SECONDS: Final[float] = 2.0 -FREQUENCY: Final[float] = 220.0 -FILTER_TEXT: Final[str] = "zz" -HELD_TIMEOUT_SECONDS: Final[float] = 60.0 - -NEW_RECORDINGS: Final[str] = "main.source.label.new_recordings" -PROGRESS: Final[str] = "main.converter.template.progress_template" - - -def recordings() -> Tuple[HomeFile, ...]: - return ( - Recording(destination=home_path(KICK), seconds=PLAYED_SECONDS, frequency=FREQUENCY), - Recording(destination=home_path(SNARE), seconds=PLAYED_SECONDS, frequency=FREQUENCY), - *( - Recording(destination=home_path(PAIR) / name, seconds=PLAYED_SECONDS, frequency=FREQUENCY) - for name in PAIR_TAKES - ), - ) - - -@pytest.fixture -def world() -> World: - return World(state=lived_in_world().state, application_config=None, config=None, files=recordings()) - - -def ticked(screen: Screen, path: Path) -> Dict[ChannelName, bool]: - return {channel: screen.main.converter.list.channel_ticked(path, channel) for channel in ChannelName} - - -class TestTheCardFollowsThePick: - """Source settings names the row picked, recording or folder, and reads New recordings while none is.""" - - def test_it_names_each_pick_in_turn(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - - def nothing_picked(screen: Screen) -> None: - gather(screen, home_path(KICK), home_path(PAIR)) - - assert main.source.subject() == screen.words(NEW_RECORDINGS) - - def a_recording_picked(screen: Screen) -> None: - listing.pick(home_path(KICK)) - - screen.expect(main.source.subject, home_path(KICK).stem.__eq__, description="the recording named") - assert listing.is_picked(home_path(KICK)) - assert {channel: main.source.channel_ticked(channel) for channel in ChannelName} == ticked( - screen, home_path(KICK) - ) - - def a_folder_picked(screen: Screen) -> None: - listing.pick(home_path(PAIR)) - - expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) - screen.expect(main.source.subject, expected.__eq__, description="the folder named") - assert not listing.is_picked(home_path(KICK)) - - screen.scenario(nothing_picked, a_recording_picked, a_folder_picked).run() - - -class TestAFoldersBoxes: - """A folder's box reads partial where its recordings disagree; one click settles them all and the next lets go.""" - - def test_a_box_inside_turns_the_folders_partial_and_the_folders_settles_them(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - folder = home_path(PAIR) - first, second = (folder / name for name in PAIR_TAKES) - channel = ChannelName.PULSE1 - - def open_the_folder(screen: Screen) -> None: - gather(screen, folder) - listing.toggle_folder(folder) - screen.expect(partial(listing.is_open, folder), bool, description="the folder open") - assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) - - def a_box_inside_changes_that_row_alone(screen: Screen) -> None: - listing.tick(first, channel) - - screen.expect(partial(listing.channel_ticked, first, channel), operator.not_, description="the box let go") - assert listing.channel_ticked(second, channel) - screen.expect( - partial(listing.channel_theme, folder, channel), - PARTIAL_CHANNEL_THEME_TAGS[channel].__eq__, - description="the folder's box partial", - ) - assert not listing.channel_ticked(folder, channel) - - def the_folders_box_settles_them_all(screen: Screen) -> None: - listing.tick(folder, channel) - - screen.expect(partial(listing.channel_ticked, folder, channel), bool, description="the folder's box held") - assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) - - def the_next_click_lets_them_go(screen: Screen) -> None: - listing.tick(folder, channel) - - screen.expect(partial(listing.channel_ticked, folder, channel), operator.not_, description="let go") - assert not listing.channel_ticked(first, channel) and not listing.channel_ticked(second, channel) - - screen.scenario( - open_the_folder, - a_box_inside_changes_that_row_alone, - the_folders_box_settles_them_all, - the_next_click_lets_them_go, - ).run() - - -class TestTheCardFollowsABoxClicked: - """Ticking a box inside an open folder brings the card to that row, and the folder's own box to the folder.""" - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: a box clicked in the list leaves Source settings where it stood", - ) - def test_the_card_names_the_row_whose_box_was_clicked(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - folder = home_path(PAIR) - first = folder / PAIR_TAKES[0] - gather(screen, folder) - listing.toggle_folder(folder) - screen.expect(partial(listing.is_open, folder), bool, description="the folder open") - - listing.tick(first, ChannelName.PULSE1) - - screen.expect(main.source.subject, first.stem.__eq__, description="the card on the row clicked") - listing.tick(folder, ChannelName.PULSE1) - expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) - screen.expect(main.source.subject, expected.__eq__, description="the card on the folder") - - -class TestAChannelKey: - """A channel's key settles that channel on the row picked and no other; with nothing picked it changes nothing.""" - - def test_it_reaches_the_picked_row_alone(self, screen: Screen) -> None: - listing = screen.main.converter.list - before: List[Dict[ChannelName, bool]] = [] - - def press_with_nothing_picked(screen: Screen) -> None: - gather(screen, home_path(KICK), home_path(SNARE)) - before.extend((ticked(screen, home_path(KICK)), ticked(screen, home_path(SNARE)))) - - screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) - - def press_with_a_row_picked(screen: Screen) -> None: - listing.pick(home_path(KICK)) - - screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) - - expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} - screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped once") - assert ticked(screen, home_path(SNARE)) == before[1] - - screen.scenario(press_with_nothing_picked, press_with_a_row_picked).run() - - -class TestThePickHoldsThrough: - """The pick stands through a second click, a double-click that plays the row, and Esc stopping it.""" - - def test_clicks_double_clicks_and_esc_keep_the_pick(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - playback = screen.sequencer.playback - - def pick_twice(screen: Screen) -> None: - gather(screen, home_path(KICK), home_path(SNARE)) - listing.pick(home_path(KICK)) - screen.expect(partial(listing.is_picked, home_path(KICK)), bool, description="picked") - - listing.pick(home_path(KICK)) - - assert listing.is_picked(home_path(KICK)) - assert main.source.subject() == home_path(KICK).stem - - def a_double_click_plays_and_keeps_it(screen: Screen) -> None: - screen.hand.double_click(listing.row(home_path(KICK))) - - screen.expect(playback.can_stop, bool, description="the recording playing") - assert listing.is_picked(home_path(KICK)) - - def esc_stops_and_keeps_it(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.STOP) - - screen.expect(playback.can_stop, operator.not_, description="playback stopped") - assert listing.is_picked(home_path(KICK)) - assert main.source.subject() == home_path(KICK).stem - - screen.scenario(pick_twice, a_double_click_plays_and_keeps_it, esc_stops_and_keeps_it).run() - - -class TestTheKeysOfTheList: - """Del and the channel keys reach the picked row only while the Converter card is open and no field holds the keys.""" - - def test_a_collapsed_card_keeps_del_from_the_list(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - card = main.card(TAG_MAIN_CONVERTER_PANEL) - - def del_with_the_card_collapsed(screen: Screen) -> None: - gather(screen, home_path(KICK), home_path(SNARE)) - listing.pick(home_path(KICK)) - card.toggle() - screen.expect(card.is_collapsed, bool, description="the card collapsed") - - screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) - - def del_with_the_card_open(screen: Screen) -> None: - card.toggle() - screen.expect(card.is_collapsed, operator.not_, description="the card open") - listing.pick(home_path(SNARE)) - - screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) - - screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") - assert listing.rows() == [listing.row(home_path(KICK))] - - screen.scenario(del_with_the_card_collapsed, del_with_the_card_open).run() - - def test_a_focused_field_takes_del_and_the_channel_keys(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - field = compose_tag(TAG_MAIN_EXPLORER_PANEL, SUF_INPUT_SEARCH) - before: List[Dict[ChannelName, bool]] = [] - - def keys_into_the_field(screen: Screen) -> None: - gather(screen, home_path(KICK)) - listing.pick(home_path(KICK)) - before.append(ticked(screen, home_path(KICK))) - screen.hand.click(field) - screen.hand.type_text(FILTER_TEXT) - - screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) - screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) - - def the_same_key_reaches_the_row_once_the_field_lets_go(screen: Screen) -> None: - listing.pick(home_path(KICK)) - - screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) - - expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} - screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped") - assert listing.rows() == [listing.row(home_path(KICK))] - - screen.scenario(keys_into_the_field, the_same_key_reaches_the_row_once_the_field_lets_go).run() - - -class TestARightClickMovesThePick: - """A right-click on a row not picked picks it, and Del then removes that row.""" - - def test_del_after_a_right_click_removes_the_row_clicked(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - menu = screen.context_menu - - def right_click_another_row(screen: Screen) -> None: - gather(screen, home_path(KICK), home_path(SNARE)) - listing.pick(home_path(KICK)) - - screen.hand.right_click(listing.row(home_path(SNARE))) - - screen.expect(menu.is_shown, bool, description="the menu") - screen.expect(main.source.subject, home_path(SNARE).stem.__eq__, description="the card on the row clicked") - menu.dismiss() - screen.expect(menu.is_shown, operator.not_, description="the menu put away") - - def del_removes_it(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) - - screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") - assert listing.rows() == [listing.row(home_path(KICK))] - - screen.scenario(right_click_another_row, del_removes_it).run() - - -class TestTheListDuringARun: - """While a run converts the list, Del, the menu's Remove and the channel keys leave it alone.""" - - @pytest.fixture - def world(self) -> World: - return converting_world(recordings()) - - def test_the_list_stands_as_it_was_through_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: - converter = screen.main.converter - listing = converter.list - before: List[Dict[ChannelName, bool]] = [] - - def start_a_held_run(screen: Screen) -> None: - gather(screen, home_path(KICK), home_path(SNARE)) - listing.pick(home_path(KICK)) - before.append(ticked(screen, home_path(KICK))) - - converter.press_action() - - started = screen.words(PROGRESS).format(0, 2) - screen.bridge.expect( - converter.status, - lambda status: status.startswith(started), - description="the run under way on its first recording", - timeout=HELD_TIMEOUT_SECONDS, - ) - assert converter.action() == screen.words(CANCEL_RUN) - - def the_keys_and_the_menu_reach_nothing(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) - screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) - - assert not screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK)))).enabled - assert not screen.context_menu.is_shown() - assert listing.rows() == [listing.row(home_path(KICK)), listing.row(home_path(SNARE))] - assert ticked(screen, home_path(KICK)) == before[0] - - def after_the_run_the_menu_and_del_reach_the_row(screen: Screen) -> None: - conversion_hold.release() - screen.bridge.expect( - converter.end_prompt.is_shown, - bool, - description="the run's end", - timeout=HELD_TIMEOUT_SECONDS, - ) - converter.end_prompt.cancel() - screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end prompt closed") - assert ticked(screen, home_path(KICK)) == before[0] - - choose_from_the_row_menu(screen, home_path(SNARE), screen.words(REMOVE_RECORDING)) - screen.expect( - partial(listing.has_row, home_path(SNARE)), operator.not_, description="removed from the menu" - ) - listing.pick(home_path(KICK)) - screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) - - screen.expect(partial(listing.has_row, home_path(KICK)), operator.not_, description="removed by Del") - - screen.scenario( - start_a_held_run, - the_keys_and_the_menu_reach_nothing, - after_the_run_the_menu_and_del_reach_the_row, - ).run() - - -HUNDREDS: Final[str] = "Hundreds" -HUNDREDS_COUNT: Final[int] = 300 -TINY_SECONDS: Final[float] = 0.01 - - -class TestHundredsGathered: - """Some three hundred recordings gathered: the card collapses and expands, the interface answers meanwhile, and the list scrolls end to end.""" - - @pytest.fixture - def world(self) -> World: - files = tuple( - Recording( - destination=home_path(HUNDREDS) / f"take{index:03d}.wav", seconds=TINY_SECONDS, frequency=FREQUENCY - ) - for index in range(HUNDREDS_COUNT) - ) - return World(state=lived_in_world().state, application_config=None, config=None, files=files) - - def test_collapse_expand_and_scroll_end_to_end(self, screen: Screen) -> None: - main = screen.main - listing = main.converter.list - card = main.card(TAG_MAIN_CONVERTER_PANEL) - folder = home_path(HUNDREDS) - last = folder / f"take{HUNDREDS_COUNT - 1:03d}.wav" - - def gather_and_collapse(screen: Screen) -> None: - gather(screen, folder) - card.toggle() - - screen.expect(card.is_collapsed, bool, description="the card collapsed") - - def the_interface_answers_meanwhile(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") - screen.tabs.bring_to_front(Tab.MAIN) - screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") - - def expand_and_scroll_end_to_end(screen: Screen) -> None: - card.toggle() - screen.expect(card.is_collapsed, operator.not_, description="the card open") - listing.toggle_folder(folder) - screen.expect(partial(listing.is_open, folder), bool, description="the folder open") - - screen.hand.scroll_to_end(listing.tags.region(str(folder))) - - screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") - screen.hand.scroll_into_view(listing.row(last)) - screen.hand.hover(listing.row(last)) - - screen.scenario(gather_and_collapse, the_interface_answers_meanwhile, expand_and_scroll_end_to_end).run() diff --git a/tests/screens/main/test_run.py b/tests/screens/main/test_run.py deleted file mode 100644 index 5c0d3aa3b..000000000 --- a/tests/screens/main/test_run.py +++ /dev/null @@ -1,405 +0,0 @@ -import multiprocessing -import operator -import shutil -from pathlib import Path -from typing import Dict, Final, FrozenSet, List, Tuple - -import pytest - -from sampletones_application.constants.output import OutputKind -from sampletones_application.tags.main import TAG_MAIN_CONVERTER_GROUP_INPUT, TAG_MAIN_CONVERTER_GROUP_ORDER -from sampletones_core.constants.enums import ChannelName -from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction -from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.dearpygui.items import read_item -from tests.suite.screens.holds import ConversionHold -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording -from tests.suite.screens.steps.main import explorer_row, gather, give_each_its_own_channel, home_path -from tests.suite.screens.vocabulary.converter import CANCEL_RUN, CONVERT_NOTHING, CONVERT_ONE, CONVERT_SEVERAL -from tests.suite.screens.world import HomeFile, World, converting_world - -SECONDS: Final[float] = 0.3 -FREQUENCY: Final[float] = 220.0 -RUN_TIMEOUT_SECONDS: Final[float] = 120.0 -BASS: Final[str] = "bass.wav" -LEAD: Final[str] = "lead.wav" -DRUMS: Final[str] = "drums.wav" -ALBUM: Final[str] = "Album" -DISC: Final[str] = "Disc2" -ALBUM_TAKES: Final[Tuple[str, ...]] = ("one.wav", "two.wav") -DISC_TAKES: Final[Tuple[str, ...]] = ("three.wav",) -RECONSTRUCTION_SUFFIX: Final[str] = ".stn" -OTHER_RECONSTRUCTION: Final[str] = "other.stn" - -MIX_SEVERAL: Final[str] = "main.converter.template.mix_recordings" -RECONSTRUCT_FILE: Final[str] = "main.explorer.label.context_reconstruct_file" -RUN_FAILED: Final[str] = "main.converter.message.status_error" - - -def recording(path: Path) -> HomeFile: - return Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) - - -def run_world() -> World: - paths = [ - home_path(BASS), - home_path(LEAD), - home_path(DRUMS), - *(home_path(ALBUM) / name for name in ALBUM_TAKES), - *(home_path(ALBUM) / DISC / name for name in DISC_TAKES), - ] - return converting_world(tuple(recording(path) for path in paths)) - - -@pytest.fixture -def world() -> World: - return run_world() - - -def run_to_its_end(screen: Screen) -> None: - """Presses the button naming the run and waits for the question its end asks, which it closes.""" - converter = screen.main.converter - converter.press_action() - wait_for_the_end(screen) - - -def wait_for_the_end(screen: Screen) -> None: - converter = screen.main.converter - screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) - converter.end_prompt.cancel() - screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end closed") - - -def written(destination: Path) -> List[Path]: - return sorted(destination.rglob(f"*{RECONSTRUCTION_SUFFIX}")) - - -def converted_channels(path: Path) -> FrozenSet[ChannelName]: - """The channels a written reconstruction approximates, read off the file.""" - return frozenset(Reconstruction.load(path).approximations) - - -def modified(paths: List[Path]) -> Dict[Path, int]: - return {path: path.stat().st_mtime_ns for path in paths} - - -class TestTheButtonNamesTheRun: - """The button under the list names the run each time it changes.""" - - def test_one_several_a_mix_and_none(self, screen: Screen) -> None: - main = screen.main - converter = main.converter - - def one(screen: Screen) -> None: - gather(screen, home_path(BASS)) - - assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(BASS).stem) - - def several(screen: Screen) -> None: - gather(screen, home_path(LEAD)) - - assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) - - def a_mix(screen: Screen) -> None: - main.choose_output(OutputKind.MIXED) - - screen.expect(converter.action, screen.words(MIX_SEVERAL).format(count=2).__eq__, description="a mix named") - - def none(screen: Screen) -> None: - converter.list.remove(home_path(BASS)) - converter.list.remove(home_path(LEAD)) - - screen.expect(converter.action, screen.words(CONVERT_NOTHING).__eq__, description="nothing to name") - - screen.scenario(one, several, a_mix, none).run() - - -class TestTheLinesOfTheCard: - """Order joins only from a mix's second recording, Destination always stands, and Input only while a run reads.""" - - def test_order_and_destination(self, screen: Screen) -> None: - main = screen.main - converter = main.converter - - def order_waits_for_a_second_recording_in_a_mix(screen: Screen) -> None: - assert converter.destination() - gather(screen, home_path(BASS)) - main.choose_output(OutputKind.MIXED) - screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") - - assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown - - def the_second_brings_it(screen: Screen) -> None: - gather(screen, home_path(LEAD)) - - screen.expect( - lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown, - bool, - description="Order joining", - ) - assert converter.destination() - - screen.scenario(order_waits_for_a_second_recording_in_a_mix, the_second_brings_it).run() - - def test_input_stands_while_a_run_reads(self, screen: Screen, conversion_hold: ConversionHold) -> None: - converter = screen.main.converter - - def no_input_before_the_run(screen: Screen) -> None: - gather(screen, home_path(BASS)) - - assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown - - def input_while_it_reads(screen: Screen) -> None: - converter.press_action() - - screen.bridge.expect( - lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown, - bool, - description="the Input line", - timeout=RUN_TIMEOUT_SECONDS, - ) - - def no_input_after_it(screen: Screen) -> None: - conversion_hold.release() - wait_for_the_end(screen) - - assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown - - screen.scenario(no_input_before_the_run, input_while_it_reads, no_input_after_it).run() - - -def written_as(path: Path) -> Path: - """The one reconstruction a run wrote for the recording at ``path``, wherever below the folder it went.""" - found = [file for file in written(RECONSTRUCTIONS_DIRECTORY) if file.stem == path.stem] - assert len(found) == 1, found - return found[0] - - -class TestWhereARunWrites: - """A run writes each recording with the channels its row gave, where the Destination line says.""" - - def channels(self) -> Dict[Path, ChannelName]: - return {home_path(BASS): ChannelName.TRIANGLE, home_path(LEAD): ChannelName.PULSE1} - - def test_each_recording_carries_the_channels_its_row_gave(self, screen: Screen) -> None: - gather(screen, home_path(BASS), home_path(LEAD)) - give_each_its_own_channel(screen, self.channels()) - - run_to_its_end(screen) - - for path, channel in self.channels().items(): - assert converted_channels(written_as(path)) == {channel} - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: Destination names no folder a run of differing channels writes into", - ) - def test_the_destination_line_names_the_folder_the_run_writes_into(self, screen: Screen) -> None: - converter = screen.main.converter - gather(screen, home_path(BASS), home_path(LEAD)) - give_each_its_own_channel(screen, self.channels()) - destination = Path(converter.destination()) - - run_to_its_end(screen) - - assert all(written_as(path).parent == destination for path in self.channels()) - - -class TestAFolderConverts: - """A listed folder converts into a tree mirroring it; a rerun leaves what was written and makes the rest.""" - - def test_a_mirrored_tree_then_only_what_is_missing(self, screen: Screen) -> None: - converter = screen.main.converter - folder = home_path(ALBUM) - expected = [Path(name).with_suffix(RECONSTRUCTION_SUFFIX) for name in ALBUM_TAKES] + [ - (Path(DISC) / name).with_suffix(RECONSTRUCTION_SUFFIX) for name in DISC_TAKES - ] - kept: List[Dict[Path, int]] = [] - destinations: List[Path] = [] - - def convert_the_folder(screen: Screen) -> None: - gather(screen, folder) - destinations.append(Path(converter.destination())) - - run_to_its_end(screen) - - files = written(destinations[0]) - assert len(files) == len(expected) - assert all(any(file.as_posix().endswith(part.as_posix()) for file in files) for part in expected) - - def rerun_makes_only_what_is_missing(screen: Screen) -> None: - files = written(destinations[0]) - missing, *standing = files - missing.unlink() - kept.append(modified(standing)) - - run_to_its_end(screen) - - assert written(destinations[0]) == files - assert modified(standing) == kept[0] - - screen.scenario(convert_the_folder, rerun_makes_only_what_is_missing).run() - - -class TestReplacingWhatARunWrote: - """Recordings gathered by name and converted before ask first, and Convert anyway replaces exactly them.""" - - def test_cancel_writes_nothing_and_convert_anyway_replaces_those_alone(self, screen: Screen) -> None: - converter = screen.main.converter - prompt = converter.overwrite_prompt - paths = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] - state: Dict[str, Dict[Path, int]] = {} - destinations: List[Path] = [] - - def convert_three_and_set_another_beside_them(screen: Screen) -> None: - gather(screen, *paths) - destinations.append(Path(converter.destination())) - run_to_its_end(screen) - first = written(destinations[0])[0] - shutil.copyfile(first, destinations[0] / OTHER_RECONSTRUCTION) - state["before"] = modified(written(destinations[0])) - - def cancel_writes_nothing(screen: Screen) -> None: - converter.press_action() - screen.expect(prompt.is_shown, bool, description="the question about replacing") - - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - assert not converter.run_shown() - assert modified(written(destinations[0])) == state["before"] - - def convert_anyway_replaces_those_alone(screen: Screen) -> None: - converter.press_action() - screen.expect(prompt.is_shown, bool, description="the question again") - - prompt.confirm() - - wait_for_the_end(screen) - after = modified(written(destinations[0])) - other = destinations[0] / OTHER_RECONSTRUCTION - assert after[other] == state["before"][other] - assert all( - after[file] != state["before"][file] - for file in (destinations[0] / f"{path.stem}{RECONSTRUCTION_SUFFIX}" for path in paths) - ) - - screen.scenario( - convert_three_and_set_another_beside_them, - cancel_writes_nothing, - convert_anyway_replaces_those_alone, - ).run() - - -class TestReconstructingAFileWhileAListStands: - """Reconstruct file with a list gathered asks first: Keep the list converts none, Replace it converts the file.""" - - def test_keep_the_list_then_replace_it(self, screen: Screen) -> None: - converter = screen.main.converter - prompt = converter.replace_prompt - destinations: List[Path] = [] - - def ask_from_the_browser(screen: Screen) -> None: - gather(screen, home_path(BASS), home_path(LEAD)) - destinations.append(Path(converter.destination())) - screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) - screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") - screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) - - screen.expect(prompt.is_shown, bool, description="the question about the list") - - def keep_the_list(screen: Screen) -> None: - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - assert not converter.run_shown() - assert converter.list.rows() == [converter.list.row(home_path(BASS)), converter.list.row(home_path(LEAD))] - assert written(destinations[0]) == [] - - def replace_it(screen: Screen) -> None: - screen.explorer.right_click(explorer_row(screen, home_path(DRUMS))) - screen.expect(screen.context_menu.is_shown, bool, description="the menu again") - screen.context_menu.choose(screen.words(RECONSTRUCT_FILE)) - screen.expect(prompt.is_shown, bool, description="the question again") - - prompt.confirm() - - wait_for_the_end(screen) - assert [file.stem for file in written(destinations[0])] == [home_path(DRUMS).stem] - - screen.scenario(ask_from_the_browser, keep_the_list, replace_it).run() - - -class TestAHeldRun: - """A run shows its progress, Continue lets it run on, and Stop ends it with nothing written and no worker left.""" - - @pytest.mark.usefixtures("conversion_hold") - def test_progress_continue_and_stop(self, screen: Screen) -> None: - converter = screen.main.converter - prompt = converter.cancel_prompt - destinations: List[Path] = [] - - def start_it(screen: Screen) -> None: - gather(screen, home_path(BASS)) - destinations.append(Path(converter.destination())) - - converter.press_action() - - screen.bridge.expect( - converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS - ) - assert converter.action() == screen.words(CANCEL_RUN) - - def continue_lets_it_run(screen: Screen) -> None: - converter.press_action() - screen.expect(prompt.is_shown, bool, description="the question about stopping") - - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - assert converter.action() == screen.words(CANCEL_RUN) - - def stop_ends_it(screen: Screen) -> None: - converter.press_action() - screen.expect(prompt.is_shown, bool, description="the question again") - - prompt.confirm() - - screen.bridge.expect( - converter.action, - screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, - description="the run over", - timeout=RUN_TIMEOUT_SECONDS, - ) - screen.bridge.expect( - lambda: len(multiprocessing.active_children()), - operator.not_, - description="no worker left", - timeout=RUN_TIMEOUT_SECONDS, - ) - assert written(destinations[0]) == [] - - screen.scenario(start_it, continue_lets_it_run, stop_ends_it).run() - - -class TestARecordingGoneBeforeTheRun: - """A recording deleted after it was gathered is reported in words naming it when the run reaches it.""" - - def test_the_run_says_what_went_wrong(self, screen: Screen) -> None: - converter = screen.main.converter - notice = screen.error_notice - gather(screen, home_path(BASS)) - home_path(BASS).unlink() - - converter.press_action() - - screen.bridge.expect(notice.is_shown, bool, description="the failure reported", timeout=RUN_TIMEOUT_SECONDS) - words = notice.words() - screen.claim_error(home_path(BASS).name) - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the report dismissed") - assert words.startswith(screen.words(RUN_FAILED)) - assert home_path(BASS).name in words - assert converter.status() == screen.words(RUN_FAILED) diff --git a/tests/screens/prompts/closing/__init__.py b/tests/screens/prompts/closing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/closing/constants.py b/tests/screens/prompts/closing/constants.py new file mode 100644 index 000000000..256e347f9 --- /dev/null +++ b/tests/screens/prompts/closing/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 30 diff --git a/tests/screens/prompts/closing/test_during_an_edit.py b/tests/screens/prompts/closing/test_during_an_edit.py new file mode 100644 index 000000000..f33bff2c4 --- /dev/null +++ b/tests/screens/prompts/closing/test_during_an_edit.py @@ -0,0 +1,121 @@ +import operator +from typing import List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.closing.constants import SETTLING_FRAMES +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + first_raised, + marked, + raise_the_first_level_while_held, + titled, +) +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + + +class TestClosingTheWindowWhileAnEditIsOnItsWay: + """A close asked for during an edit asks about the edited reconstruction once the edit lands.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: two closes before the first is answered ask twice", + ) + def test_two_closes_ask_once(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """Two closes during a held rebuild bring one question, and Cancel leaves the screen quiet.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + + def edit_and_close_twice_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + screen.close_window() + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_edit_lands_and_one_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + screen.scenario( + edit_and_close_twice_while_the_rebuild_is_held, + the_edit_lands_and_one_question_comes, + cancel_leaves_no_question, + ).run() + + def test_an_edit_made_after_the_close_is_drawn_away( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """An edit typed after the close is dropped, and the field shows the edit that landed.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_and_close_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def edit_again_after_the_close(screen: Screen) -> None: + reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + typed[0].__eq__, + description="the field drawing the edit that landed", + ) + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving again") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_and_close_while_the_rebuild_is_held, + edit_again_after_the_close, + the_first_edit_lands_and_the_second_is_drawn_away, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/test_closing_over_a_modal.py b/tests/screens/prompts/closing/test_over_a_question.py similarity index 55% rename from tests/screens/prompts/test_closing_over_a_modal.py rename to tests/screens/prompts/closing/test_over_a_question.py index 4169d0b83..a9a770d85 100644 --- a/tests/screens/prompts/test_closing_over_a_modal.py +++ b/tests/screens/prompts/closing/test_over_a_question.py @@ -1,34 +1,25 @@ import operator -from typing import Final, List +from typing import Final import pytest from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ChannelName from sampletones_shared.paths.user import PROJECTS_DIRECTORY -from tests.suite.screens.application import Startup -from tests.suite.screens.holds import RegenerationHold +from tests.screens.prompts.closing.constants import SETTLING_FRAMES from tests.suite.screens.screen import Screen from tests.suite.screens.steps.main import convert_alone, home_path from tests.suite.screens.steps.project import retitle_project, save_project_as, saved_project_title -from tests.suite.screens.steps.reconstructions import ( - expect_open, - first_raised, - marked, - raise_the_first_level_while_held, - titled, -) from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE -from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION +from tests.suite.screens.worlds.recordings import BASS, LEAD -SETTLING_FRAMES: Final[int] = 30 PROJECT: Final[str] = "Closing.stp" SAVED_TITLE: Final[str] = "Before closing" NEW_TITLE: Final[str] = "After closing" def change_a_saved_project(screen: Screen) -> None: - """Saves a new project titled, then titles it anew, which leaves it unsaved.""" + """Saves a new titled project, then retitles it, which leaves the saved file with the first title.""" screen.project.create() retitle_project(screen, SAVED_TITLE) save_project_as(screen, PROJECTS_DIRECTORY / PROJECT) @@ -39,6 +30,7 @@ def change_a_saved_project(screen: Screen) -> None: def leave_letting_the_project_go(screen: Screen) -> None: + """Answers Exit to the question about the project and expects the saved file to keep its first title.""" prompt = screen.project.unsaved_prompt screen.expect(prompt.is_shown, bool, description="the question about the project") @@ -49,7 +41,11 @@ def leave_letting_the_project_go(screen: Screen) -> None: class TestClosingTheWindowTwiceAtOnce: - """Two closes before the first is answered ask about the unsaved project once.""" + """Two closes before the first is answered ask about the unsaved project once. + + The saved project is retitled and the window closed twice. One question comes; Cancel keeps the + application, and a further close asks once more and leaves on Exit. + """ @pytest.mark.xfail( strict=True, @@ -57,6 +53,7 @@ class TestClosingTheWindowTwiceAtOnce: reason="bugs-and-todos ยง Bugs: two closes before the first is answered ask twice", ) def test_one_question_and_cancel_keeps_the_application(self, screen: Screen) -> None: + """Two closes bring one question, and Cancel keeps the application running.""" prompt = screen.project.unsaved_prompt def close_twice(screen: Screen) -> None: @@ -84,9 +81,15 @@ def close_once_more(screen: Screen) -> None: class TestClosingTheWindowOverTheReassignQuestion: - """Closing the window while Keyboard settings asks to reassign keys asks about the project after the dialog.""" + """Closing the window while Keyboard settings asks to reassign keys asks about the project after the + dialog. + + The saved project is retitled, Redo listens and Undo's keys ask to reassign. The window close waits; + Cancel brings the dialog back, and closing the dialog brings the question about the project. + """ def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + """The question about the project comes after the reassign question and its dialog.""" settings = screen.keyboard_settings reassign = settings.reassign_prompt prompt = screen.project.unsaved_prompt @@ -130,9 +133,14 @@ def answer_the_dialog_and_its_question(screen: Screen) -> None: class TestClosingTheWindowOverTheOverwriteQuestion: - """Closing the window while the Converter asks to replace a run's files asks about the project after it.""" + """Closing the window while the Converter asks to replace a run's files asks about the project after it. + + The saved project is retitled and a run is converted again, which asks about replacing. The window + close waits; Cancel brings the question about the project. + """ def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + """The question about the project comes after the overwrite question.""" converter = screen.main.converter overwrite = converter.overwrite_prompt prompt = screen.project.unsaved_prompt @@ -167,102 +175,3 @@ def cancel_hands_the_screen_over(screen: Screen) -> None: cancel_hands_the_screen_over, leave_letting_the_project_go, ).run() - - -class TestClosingTheWindowWhileAnEditIsOnItsWay: - """A close asked for while an edit is on its way asks about the edited reconstruction once the edit lands.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: two closes before the first is answered ask twice", - ) - def test_two_closes_ask_once(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: - reconstructions = screen.reconstructions - prompt = reconstructions.unsaved_prompt - - def edit_and_close_twice_while_the_rebuild_is_held(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) - - screen.close_window() - screen.close_window() - - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - - def the_edit_lands_and_one_question_comes(screen: Screen) -> None: - regeneration_hold.release() - - screen.expect(prompt.is_shown, bool, description="the question about leaving") - screen.frames(SETTLING_FRAMES) - assert len(screen.shown_windows()) == 1 - - def cancel_leaves_no_question(screen: Screen) -> None: - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question answered") - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - - screen.scenario( - edit_and_close_twice_while_the_rebuild_is_held, - the_edit_lands_and_one_question_comes, - cancel_leaves_no_question, - ).run() - - def test_an_edit_made_after_the_close_is_drawn_away( - self, - screen: Screen, - regeneration_hold: RegenerationHold, - ) -> None: - reconstructions = screen.reconstructions - prompt = reconstructions.unsaved_prompt - typed: List[str] = [] - - def edit_and_close_while_the_rebuild_is_held(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) - - screen.close_window() - - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - - def edit_again_after_the_close(screen: Screen) -> None: - reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) - - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - - def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: - regeneration_hold.release() - - screen.expect(prompt.is_shown, bool, description="the question about leaving") - prompt.cancel() - screen.expect(prompt.is_shown, operator.not_, description="the question answered") - screen.expect( - lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), - typed[0].__eq__, - description="the field drawing the edit that landed", - ) - assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - - def leave_letting_it_go(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving again") - - prompt.confirm() - - assert screen.wait_for_exit() - - screen.scenario( - edit_and_close_while_the_rebuild_is_held, - edit_again_after_the_close, - the_first_edit_lands_and_the_second_is_drawn_away, - leave_letting_it_go, - ).run() diff --git a/tests/screens/prompts/conftest.py b/tests/screens/prompts/conftest.py index c26dae79c..7c20032b0 100644 --- a/tests/screens/prompts/conftest.py +++ b/tests/screens/prompts/conftest.py @@ -1,6 +1,7 @@ import pytest -from tests.suite.screens.world import World, documents_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import documents_world @pytest.fixture diff --git a/tests/screens/prompts/every_door/__init__.py b/tests/screens/prompts/every_door/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/every_door/cases.py b/tests/screens/prompts/every_door/cases.py new file mode 100644 index 000000000..1527eca5a --- /dev/null +++ b/tests/screens/prompts/every_door/cases.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Final, Tuple + +from tests.screens.prompts.every_door.constants import EXIT +from tests.suite.screens.vocabulary.dialogs import CLOSE + + +class Door(StrEnum): + """A gesture that puts the open document away, to replace it or to leave. + + A voice opens from a double-click on its row and from Edit on its row's menu. + """ + + BROWSER = "browser" + MENU_OPEN = "menu_open" + CLOSE = "close" + VOICE = "voice" + VOICE_MENU = "voice_menu" + CONVERSION_LOAD = "conversion_load" + EXIT = "exit" + WINDOW_CLOSE = "window_close" + + +ASKING_DOORS: Final[Tuple[Door, ...]] = tuple(Door) + + +@dataclass(frozen=True) +class Question: + """A question as the reader meets it, with its title, its words and the answers it offers.""" + + title: str + words: str + answers: Tuple[str, ...] + + +@dataclass(frozen=True) +class Standing: + """What the screen shows of the open document, which a question taken back keeps as it was.""" + + title: str + file_line: str + volume: str + windows: Tuple[str, ...] + dialogs: int diff --git a/tests/screens/prompts/every_door/constants.py b/tests/screens/prompts/every_door/constants.py new file mode 100644 index 000000000..cdb4c3fba --- /dev/null +++ b/tests/screens/prompts/every_door/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 +SAMPLE_ORDINAL: Final[int] = 0 +EXIT: Final[str] = "global.dialog.label.exit" diff --git a/tests/screens/prompts/every_door/steps.py b/tests/screens/prompts/every_door/steps.py new file mode 100644 index 000000000..12b445d50 --- /dev/null +++ b/tests/screens/prompts/every_door/steps.py @@ -0,0 +1,48 @@ +import operator + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.reconstructions import load_from_the_browser +from tests.suite.screens.steps.sequencer import double_click_voice, open_voice +from tests.suite.screens.worlds.recordings import BASS, LEAD, OTHER_RECONSTRUCTION + + +def knock(screen: Screen, door: Door, *, voice: str) -> None: + """Makes the gesture ``door`` stands for. + + A door that loads opens Other.stn, the run's bass, or ``voice``. + """ + match door: + case Door.BROWSER: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + case Door.MENU_OPEN: + screen.reconstructions.open_from_menu() + case Door.CLOSE: + screen.reconstructions.close_from_menu() + case Door.VOICE: + double_click_voice(screen, voice) + case Door.VOICE_MENU: + open_voice(screen, voice) + case Door.CONVERSION_LOAD: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + screen.main.converter.end_prompt.confirm() + screen.expect(screen.main.converter.end_prompt.is_shown, operator.not_, description="the end answered") + case Door.EXIT: + screen.press_shortcut(ShortcutId.EXIT) + case Door.WINDOW_CLOSE: + screen.close_window() + + +def nothing_asked(screen: Screen) -> None: + """Waits a few frames and expects every window to stay closed.""" + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + +def expect_title(screen: Screen, title: str) -> None: + """Waits until the window title reads ``title``.""" + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") diff --git a/tests/screens/prompts/every_door/test_edited_reconstruction.py b/tests/screens/prompts/every_door/test_edited_reconstruction.py new file mode 100644 index 000000000..025a3f9e7 --- /dev/null +++ b/tests/screens/prompts/every_door/test_edited_reconstruction.py @@ -0,0 +1,180 @@ +import operator +from typing import Callable, Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import ASKING_DOORS, Door, Question, Standing +from tests.screens.prompts.every_door.constants import EXIT, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + marked, + raise_the_first_level, + remove_from_the_browser, + titled, +) +from tests.suite.screens.vocabulary.dialogs import ( + CANCEL, + CLOSE, + CLOSE_MESSAGE, + CLOSE_TITLE, + DISCARD, + EDIT_MESSAGE, + EDIT_TITLE, + EXIT_RECONSTRUCTION_MESSAGE, + LOAD_MESSAGE, + LOAD_TITLE, + SAVE, +) +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_SAMPLE + +EXIT_TITLE: Final[str] = "global.dialog.title.exit_confirmation" + + +def question_at(screen: Screen, door: Door) -> Question: + """The question ``door`` asks about a standalone reconstruction with unsaved changes, in the words of the + language file. + """ + match door: + case Door.BROWSER | Door.MENU_OPEN | Door.CONVERSION_LOAD: + return Question( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.CLOSE: + return Question( + screen.words(CLOSE_TITLE), + screen.words(CLOSE_MESSAGE), + (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)), + ) + case Door.VOICE | Door.VOICE_MENU: + return Question( + screen.words(EDIT_TITLE), + screen.words(EDIT_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.EXIT | Door.WINDOW_CLOSE: + return Question( + screen.words(EXIT_TITLE), + screen.words(EXIT_RECONSTRUCTION_MESSAGE), + (screen.words(SAVE), screen.words(EXIT), screen.words(CANCEL)), + ) + + +def standing(screen: Screen) -> Standing: + """What the screen shows of the open reconstruction right now.""" + reconstructions = screen.reconstructions + return Standing( + title=screen.title(), + file_line=reconstructions.file_line(), + volume=reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + windows=tuple(window.alias for window in screen.shown_windows()), + dialogs=len(screen.dialog_requests()), + ) + + +def asked(screen: Screen, door: Door) -> Question: + """Waits for the question about the reconstruction and returns it as the screen shows it.""" + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description=f"the question {door} asks") + return Question(prompt.title(), prompt.words(), prompt.answers()) + + +def asks_and_cancel_keeps_it(door: Door, *, voice: str) -> Callable[[Screen], None]: + """The step that knocks at ``door``, expects its question, and takes it back with Cancel, which keeps + everything as it stood. + """ + + def step(screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + before = standing(screen) + + knock(screen, door, voice=voice) + + assert asked(screen, door) == question_at(screen, door) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert standing(screen) == before + + step.__name__ = f"{door.value}_asks_and_cancel_keeps_it" + return step + + +def leave_letting_it_go(screen: Screen) -> None: + """Exits and answers Exit to the question about the reconstruction.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the reconstruction") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestAnEditedReconstructionAtEveryDoor: + """Every door asks about an edited reconstruction in its own words, and Cancel keeps everything as it + stood. + + The reconstruction is open beside a project and its first level is raised. Each door is knocked at in + turn; its question is compared with the expected title, words and answers, and Cancel leaves the title, + file line, volume and windows as before. The scenario ends by exiting. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + """Each door asks its own question and Cancel keeps the screen as it was.""" + + def edit_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + screen.scenario( + edit_it, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() + + +class TestAReconstructionWhoseFileWasRemovedAtEveryDoor: + """Every door asks about a reconstruction whose file was removed, as it asks about an edited one. + + The open reconstruction loses its file in the browser and shows as unsaved. Each door is knocked at in + turn, asks its question, and Cancel keeps everything as it stood. The scenario ends by exiting. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + """Each door asks its own question and Cancel keeps the screen as it was.""" + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + expect_title(screen, titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True))) + + screen.scenario( + remove_its_file, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_edited_sample.py b/tests/screens/prompts/every_door/test_edited_sample.py new file mode 100644 index 000000000..384ef7c0d --- /dev/null +++ b/tests/screens/prompts/every_door/test_edited_sample.py @@ -0,0 +1,119 @@ +import operator +from functools import partial +from typing import Callable, List + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import converted, marked, raise_the_first_level, titled, voice_title +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + + +class TestAnEditedSampleAtEveryDoor: + """An edited sample of the project is put away at every door with no question, and its edits stay in the + project. + + The sample is opened and edited. The browser, the Open menu, another voice, Close and a conversion each + go through at once, and the edited sample is still there when it is opened again. Only the exit and the + window close ask, and they ask about the project. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_the_exit_asks_about_the_project(self, screen: Screen) -> None: + """The doors go through at once and the exits ask about the project.""" + reconstructions = screen.reconstructions + edit: List[str] = [] + sample_open = partial(voice_title, screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True) + + def open_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, sample_open()) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] + + def edit_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + + edit.append(raise_the_first_level(screen, ChannelName.PULSE1, title=sample_open())) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def another_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_INSTRUMENT) + + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + assert screen.title() == titled(screen, marked(SONG.stem, unsaved=True)) + nothing_asked(screen) + open_the_sample(screen) + + def close_closes_at_once(screen: Screen) -> None: + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True))) + assert reconstructions.file_line() == "" + nothing_asked(screen) + open_the_sample(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_sample(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert not reconstructions.unsaved_prompt.is_shown() + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.title() == sample_open() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_sample, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + another_voice_opens_at_once, + close_closes_at_once, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_instrument.py b/tests/screens/prompts/every_door/test_instrument.py new file mode 100644 index 000000000..37b43cc9d --- /dev/null +++ b/tests/screens/prompts/every_door/test_instrument.py @@ -0,0 +1,126 @@ +import operator +from typing import Callable, Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import converted, edit_envelope, marked, titled, voice_title +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +FADING: Final[str] = "12 8 4" + + +class TestAnInstrumentAtEveryDoor: + """An instrument is put away at every door with no question, Close is unavailable, and the exit asks about + the project. + + The instrument is opened and its volume envelope edited. The browser, the Open menu, a sample and a + conversion each go through at once, and the edited instrument is still there when it is opened again. + Close does nothing, and the exit and the window close ask about the project. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_close_stands_aside(self, screen: Screen) -> None: + """The doors go through at once, Close is unavailable and the exits ask about the project.""" + reconstructions = screen.reconstructions + edited = titled(screen, marked(SONG.stem, unsaved=True)) + + def open_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="its edit drawn", + ) + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + + edit_envelope(screen, channel=ChannelName.PULSE1, feature=FeatureKey.VOLUME, sequence=FADING, title=edited) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + assert not reconstructions.instruments.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_instrument(screen) + + def a_sample_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True)) + assert not reconstructions.instruments.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert reconstructions.instruments.offers_audition() + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING + assert screen.title() == edited + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_instrument(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert reconstructions.instruments.offers_audition() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_instrument, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_sample_opens_at_once, + close_has_nothing_to_close, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_nothing_open.py b/tests/screens/prompts/every_door/test_nothing_open.py new file mode 100644 index 000000000..d8baf834d --- /dev/null +++ b/tests/screens/prompts/every_door/test_nothing_open.py @@ -0,0 +1,83 @@ +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.reconstructions import converted, titled, voice_title +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_SAMPLE + + +class TestNothingOpenAtEveryDoor: + """With nothing open, every door that loads loads at once, and Close is unavailable.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_door_loads_at_once(self, screen: Screen) -> None: + """The browser, the Open menu, a voice and a conversion each load at once and the title follows.""" + reconstructions = screen.reconstructions + empty = titled(screen, SONG.stem) + + def close_it_again(screen: Screen) -> None: + reconstructions.close_from_menu() + expect_title(screen, empty) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert screen.title() == empty + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + close_it_again(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + screen.scenario( + close_has_nothing_to_close, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_voice_opens_at_once, + the_conversion_loads_at_once, + ).run() + + def test_the_window_closes_at_once(self, screen: Screen) -> None: + """Closing the window leaves the application at once.""" + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the song open") + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/every_door/test_saved_reconstruction.py b/tests/screens/prompts/every_door/test_saved_reconstruction.py new file mode 100644 index 000000000..3b327c36d --- /dev/null +++ b/tests/screens/prompts/every_door/test_saved_reconstruction.py @@ -0,0 +1,85 @@ +import pytest + +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import home_path +from tests.suite.screens.steps.reconstructions import converted, expect_open, load_from_the_browser, titled, voice_title +from tests.suite.screens.worlds.recordings import BASS, OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION, SONG, SONG_SAMPLE + + +class TestASavedReconstructionAtEveryDoor: + """A reconstruction with nothing unsaved is put away at every door at once, and leaving leaves.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_no_door_asks(self, screen: Screen) -> None: + """Every door replaces, closes or leaves at once.""" + + def the_browser_loads_at_once(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OPEN_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OPEN_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + + def close_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem)) + nothing_asked(screen) + + def the_window_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() + + screen.scenario( + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + the_conversion_loads_at_once, + a_voice_opens_at_once, + close_closes_at_once, + the_window_closes_at_once, + ).run() + + def test_exit_leaves_at_once(self, screen: Screen) -> None: + """The exit shortcut leaves the application at once.""" + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.EXIT, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/keyboard/__init__.py b/tests/screens/prompts/keyboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/keyboard/constants.py b/tests/screens/prompts/keyboard/constants.py new file mode 100644 index 000000000..e20e3358d --- /dev/null +++ b/tests/screens/prompts/keyboard/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 diff --git a/tests/screens/prompts/keyboard/steps.py b/tests/screens/prompts/keyboard/steps.py new file mode 100644 index 000000000..0154f8229 --- /dev/null +++ b/tests/screens/prompts/keyboard/steps.py @@ -0,0 +1,18 @@ +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + + +def tab(screen: Screen, times: int) -> None: + """Presses the shortcut for the next control in a dialog ``times`` times.""" + for _ in range(times): + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + + +def enter(screen: Screen) -> None: + """Presses the shortcut that activates the focused control of a dialog.""" + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + +def escape(screen: Screen) -> None: + """Presses the shortcut that cancels a dialog.""" + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) diff --git a/tests/screens/prompts/keyboard/test_display_question.py b/tests/screens/prompts/keyboard/test_display_question.py new file mode 100644 index 000000000..6fe8098dc --- /dev/null +++ b/tests/screens/prompts/keyboard/test_display_question.py @@ -0,0 +1,56 @@ +import operator +from typing import List + +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.screen import Screen + + +class TestTheDisplayQuestionFromTheKeyboard: + """The question Escape asks of changed Display settings answers to the keys, as does the dialog after it. + + The user flips Vertical sync and presses Escape. Enter keeps the dialog open with the change. Escape + again, then Tab and Enter, discards the change; the dialog reopens with the original value. + """ + + def test_enter_keeps_editing_and_tab_enter_discards(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + def escape_asks(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, bool, description="the discard question") + + def enter_keeps_editing(screen: Screen) -> None: + enter(screen) + + screen.expect(settings.is_shown, bool, description="the dialog back") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def escape_then_tab_and_enter_discard(screen: Screen) -> None: + escape(screen) + screen.expect(prompt.is_shown, bool, description="the discard question again") + + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not settings.is_shown() + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog opened again") + assert settings.vsync() == original[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed") + + screen.scenario(change_vertical_sync, escape_asks, enter_keeps_editing, escape_then_tab_and_enter_discard).run() diff --git a/tests/screens/prompts/keyboard/test_exit_chain.py b/tests/screens/prompts/keyboard/test_exit_chain.py new file mode 100644 index 000000000..9ef3fcbae --- /dev/null +++ b/tests/screens/prompts/keyboard/test_exit_chain.py @@ -0,0 +1,103 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.keyboard.constants import SETTLING_FRAMES +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import retitle_project, saved_project_title +from tests.suite.screens.steps.reconstructions import expect_open, marked, raise_the_first_level, titled +from tests.suite.screens.vocabulary.dialogs import EXIT_PROJECT_MESSAGE, EXIT_RECONSTRUCTION_MESSAGE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +RETITLED: Final[str] = "Retitled from the keyboard" + + +class TestTheExitChainFromTheKeyboard: + """Tab, Enter and Escape answer the question about the reconstruction that the project's answer opens. + + Each question starts on Cancel, so one Tab reaches Save and two reach Exit. The scenario opens a + project beside an edited reconstruction and retitles the project. Exit asks about the project, and Exit + on that question asks about the reconstruction. Escape there keeps the application and both changes. + Then the keys save the project, leave the reconstruction's file as it was, and close the application. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_second_keeps_everything_and_the_keys_then_save_and_leave(self, screen: Screen) -> None: + project_prompt = screen.project.unsaved_prompt + reconstruction_prompt = screen.reconstructions.unsaved_prompt + stored: List[bytes] = [] + + def open_the_project_and_change_both(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + screen.answer_next_dialog(DialogKind.OPEN, SONG) + screen.project.open() + screen.expect( + screen.sequencer.voices.names, [SONG_SAMPLE, SONG_INSTRUMENT].__eq__, description="the song's voices" + ) + retitle_project(screen, RETITLED) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + def exit_asks_about_the_project(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project_prompt.is_shown, bool, description="the question about the project") + assert project_prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + + def exit_on_the_project_asks_about_the_reconstruction(screen: Screen) -> None: + tab(screen, 2) + enter(screen) + + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + assert reconstruction_prompt.words() == screen.words(EXIT_RECONSTRUCTION_MESSAGE) + assert not project_prompt.is_shown() + + def escape_keeps_the_application_and_both_changes(screen: Screen) -> None: + escape(screen) + + screen.expect(reconstruction_prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert screen.shown_windows() == () + assert screen.title() == titled( + screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True) + ) + assert saved_project_title(SONG) != RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_keys_save_the_project_and_leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(project_prompt.is_shown, bool, description="the question about the project again") + tab(screen, 1) + enter(screen) + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + + tab(screen, 2) + enter(screen) + + assert screen.wait_for_exit() + assert saved_project_title(SONG) == RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + open_the_project_and_change_both, + exit_asks_about_the_project, + exit_on_the_project_asks_about_the_reconstruction, + escape_keeps_the_application_and_both_changes, + the_keys_save_the_project_and_leave, + ).run() diff --git a/tests/screens/prompts/keyboard/test_load_chain.py b/tests/screens/prompts/keyboard/test_load_chain.py new file mode 100644 index 000000000..6043605d7 --- /dev/null +++ b/tests/screens/prompts/keyboard/test_load_chain.py @@ -0,0 +1,83 @@ +import operator +from typing import List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import convert_alone, home_path +from tests.suite.screens.steps.reconstructions import ( + converted, + edited_title, + expect_open, + first_level_raised, + leading, + raise_the_first_level, + stored_levels, +) +from tests.suite.screens.worlds.recordings import BASS, LEAD, OPEN_RECONSTRUCTION + + +class TestTheLoadChainFromTheKeyboard: + """The keys answer Load at a run's end, and the question about unsaved changes that Load opens. + + The scenario edits the open reconstruction and converts a recording. Tab and Enter choose Load, which + asks about the changes. Escape keeps the edited reconstruction open. After a second conversion, Tab and + Enter choose Save, which writes the edit and opens the conversion. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_keeps_the_edits_and_the_keys_then_save_and_load(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = screen.reconstructions.unsaved_prompt + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) + + def load_from_the_keyboard(screen: Screen, recording: str) -> None: + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert not converter.end_prompt.is_shown() + + def escape_keeps_the_edited_one(screen: Screen) -> None: + load_from_the_keyboard(screen, BASS) + + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen) + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def save_and_load_from_the_keyboard(screen: Screen) -> None: + load_from_the_keyboard(screen, LEAD) + + tab(screen, 1) + enter(screen) + + expect_open(screen, converted(home_path(LEAD))) + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + escape_keeps_the_edited_one, + save_and_load_from_the_keyboard, + ).run() diff --git a/tests/screens/prompts/keyboard/test_save_that_asks_where.py b/tests/screens/prompts/keyboard/test_save_that_asks_where.py new file mode 100644 index 000000000..e8c798a1b --- /dev/null +++ b/tests/screens/prompts/keyboard/test_save_that_asks_where.py @@ -0,0 +1,83 @@ +import operator +from typing import Final + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.prompts.keyboard.constants import SETTLING_FRAMES +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import edited_title, expect_open, remove_from_the_browser, titled +from tests.suite.screens.vocabulary.dialogs import CLOSE_MESSAGE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" + + +class TestASaveThatAsksWhereFromTheKeyboard: + """The question a dismissed save dialog brings back answers to the keys like the first one. + + The open reconstruction loses its file in the browser, so closing it asks and Save asks where to write. + The save dialog is dismissed and the question returns. Escape keeps the reconstruction open. Closing + again, the keys choose Save, the save dialog names a file, and the reconstruction is written there and + closed. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_returned_question_then_the_keys_save_where_asked(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def take_its_file_away(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect(screen.title, edited_title(screen).__eq__, description="the reconstruction left unsaved") + + def save_asks_where_and_a_dismissed_dialog_asks_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.words() == screen.words(CLOSE_MESSAGE) + screen.answer_next_dialog(DialogKind.SAVE, None) + + tab(screen, 1) + enter(screen) + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + assert len(prompt.shown_windows()) == 1 + + def escape_keeps_it_open(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.title() == edited_title(screen) + assert screen.shown_windows() == () + + def the_keys_save_it_where_asked_and_close_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + tab(screen, 1) + enter(screen) + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + assert not prompt.is_shown() + + screen.scenario( + take_its_file_away, + save_asks_where_and_a_dismissed_dialog_asks_again, + escape_keeps_it_open, + the_keys_save_it_where_asked_and_close_it, + ).run() diff --git a/tests/screens/prompts/load/__init__.py b/tests/screens/prompts/load/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/test_load_chain.py b/tests/screens/prompts/load/test_conversion_over_edits.py similarity index 89% rename from tests/screens/prompts/test_load_chain.py rename to tests/screens/prompts/load/test_conversion_over_edits.py index 930dffc84..9e61502eb 100644 --- a/tests/screens/prompts/test_load_chain.py +++ b/tests/screens/prompts/load/test_conversion_over_edits.py @@ -5,12 +5,13 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.application import Startup +from tests.suite.screens.application.startup import Startup from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import StoredReconstruction +from tests.suite.screens.seeds.reconstructions import StoredReconstruction from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, convert_alone, home_path from tests.suite.screens.steps.reconstructions import ( converted, + edited_title, expect_open, first_level_raised, leading, @@ -21,14 +22,17 @@ titled, ) from tests.suite.screens.vocabulary.dialogs import CANCEL, DISCARD, LOAD_MESSAGE, LOAD_TITLE, SAVE -from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION +from tests.suite.screens.worlds.recordings import BASS, LEAD, OPEN_RECONSTRUCTION PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) REPLACED_MESSAGE: Final[str] = "global.dialog.message.load_replaced_reconstruction" def convert_and_ask_to_load(screen: Screen, recording: str) -> None: - """Converts the home's ``recording`` alone on Pulse 1, leaving a volume to edit, and answers Load at the end.""" + """Converts the home's ``recording`` alone on Pulse 1, leaving a volume to edit. + + Answers Load at the run's end. + """ converter = screen.main.converter convert_alone( screen, @@ -52,15 +56,12 @@ def load_question(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: def asked(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + """Waits for the question about unsaved changes and returns its title, words and answers.""" prompt = screen.reconstructions.unsaved_prompt screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") return prompt.title(), prompt.words(), prompt.answers() -def edited_title(screen: Screen) -> str: - return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - - class TestLoadingAConversionOverAnEditedReconstruction: """Load at a run's end, with an edited reconstruction open, asks about its changes first. @@ -70,9 +71,14 @@ class TestLoadingAConversionOverAnEditedReconstruction: @pytest.fixture def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) def test_cancel_keeps_the_edits_and_save_writes_them_before_loading(self, screen: Screen) -> None: + """Cancel keeps the edits and the file as they were. + + A second Load then saves the edit and opens the conversion. + """ reconstructions = screen.reconstructions prompt = reconstructions.unsaved_prompt typed: List[str] = [] @@ -120,6 +126,10 @@ def save_writes_the_changes_and_loads_the_next(screen: Screen) -> None: ).run() def test_discard_loads_the_conversion_and_leaves_the_file(self, screen: Screen) -> None: + """Discard opens the conversion and keeps the file as it was. + + A later save of the reloaded file writes only the new edit. + """ reconstructions = screen.reconstructions prompt = reconstructions.unsaved_prompt stored: List[bytes] = [] @@ -162,7 +172,11 @@ def the_file_takes_a_save_made_later(screen: Screen) -> None: class TestAConversionWrittenOverTheOpenFile: - """A run writing over the edited reconstruction open offers Discard and Cancel alone, Cancel keeping the edits.""" + """A run writing over the edited reconstruction open offers Discard and Cancel only. + + The scenario converts a recording and edits the loaded result. A second run replaces that file and + its end asks about it. Cancel keeps the edits, and Discard loads what the run wrote. + """ def test_only_discard_and_cancel_and_each_does_what_it_says(self, screen: Screen) -> None: reconstructions = screen.reconstructions @@ -233,13 +247,15 @@ def discard_loads_what_the_run_wrote(screen: Screen) -> None: class TestReloadingACleanReconstruction: - """A reconstruction with nothing unsaved loads its own file again without a question, as the file now stands.""" + """A reconstruction with every change saved loads its own file again at once, as the file now stands.""" @pytest.fixture def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) def test_the_file_changed_on_disk_loads_as_it_stands(self, screen: Screen) -> None: + """After the file is rewritten on disk, loading it from the browser shows the rewritten content.""" reconstructions = screen.reconstructions standing: List[str] = [] diff --git a/tests/screens/prompts/modals/__init__.py b/tests/screens/prompts/modals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/modals/constants.py b/tests/screens/prompts/modals/constants.py new file mode 100644 index 000000000..256e347f9 --- /dev/null +++ b/tests/screens/prompts/modals/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 30 diff --git a/tests/screens/prompts/modals/test_held_rebuild.py b/tests/screens/prompts/modals/test_held_rebuild.py new file mode 100644 index 000000000..1d2fea476 --- /dev/null +++ b/tests/screens/prompts/modals/test_held_rebuild.py @@ -0,0 +1,187 @@ +import operator +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import ( + expect_open, + load_from_the_browser, + marked, + raise_the_first_level_while_held, + remove_from_the_browser, + titled, +) +from tests.suite.screens.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION + +REBUILD_FAILURE: Final[str] = "The rebuild broke on its worker" + + +class RebuildBrokeError(RuntimeError): + """The failure a held rebuild is let go with, standing for a rebuild that raised.""" + + +class TestTwoGesturesWaitingOnAHeldRebuild: + """A load and a close asked for while an edit is on its way each ask their question once it lands, in + order. + + A first level is raised while the rebuild is held, then a load and a close are asked for. Both wait; + when the rebuild is released the load asks first, Cancel brings the close's question, and Cancel again + leaves the edited reconstruction open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_their_questions_come_one_after_the_other( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """The load asks first, the close asks next, and then the screen is quiet.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + def load_and_close_while_it_is_held(screen: Screen) -> None: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def the_load_asks_first(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the first question") + assert prompt.title() == screen.words(LOAD_TITLE) + assert len(screen.shown_windows()) == 1 + + def the_close_asks_next(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.title, screen.words(CLOSE_TITLE).__eq__, description="the second question") + assert len(screen.shown_windows()) == 1 + + def nothing_asks_after_them(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the questions answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_while_the_rebuild_is_held, + load_and_close_while_it_is_held, + the_load_asks_first, + the_close_asks_next, + nothing_asks_after_them, + leave_letting_it_go, + ).run() + + +class TestAFailedRebuildBeforeAClose: + """A rebuild that fails while a close waits on it reports the failure first, and the close asks after. + + The open reconstruction loses its file, its first level is raised while the rebuild is held, and a + close is asked for. The rebuild fails; the failure shows alone, and once it is dismissed the close + asks. Cancel keeps the reconstruction as it stood. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_first_and_the_question_after( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """The failure shows first and the close asks once the failure is dismissed.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + standing: List[str] = [] + + def edit_a_removed_reconstruction_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_while_it_is_held(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_failure_comes_first(screen: Screen) -> None: + regeneration_hold.fail(RebuildBrokeError(REBUILD_FAILURE)) + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(RebuildBrokeError.__name__) + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def the_question_comes_once_the_failure_is_read(screen: Screen) -> None: + notice.dismiss() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + + def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_a_removed_reconstruction_while_the_rebuild_is_held, + close_while_it_is_held, + the_failure_comes_first, + the_question_comes_once_the_failure_is_read, + cancel_keeps_the_reconstruction_as_it_stood, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py new file mode 100644 index 000000000..f9f1005a3 --- /dev/null +++ b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py @@ -0,0 +1,270 @@ +import operator +from typing import Dict, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, gather, home_path +from tests.suite.screens.vocabulary.converter import CONVERT_ONE +from tests.suite.screens.vocabulary.settings import LISTENING +from tests.suite.screens.worlds.recordings import BASS + + +def start_a_held_run(screen: Screen) -> None: + """Converts the home's bass on the Main tab; the hold keeps the run halfway and the function returns once + progress shows. + """ + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + + +def let_the_run_end(screen: Screen, hold: ConversionHold) -> None: + """Releases the held run and waits until it is over, while its question waits for its turn.""" + converter = screen.main.converter + hold.release() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.frames(SETTLING_FRAMES) + + +def the_end_comes_and_closes(screen: Screen) -> None: + """Expects the run's question to come once the screen is free, and Cancel puts it away.""" + end = screen.main.converter.end_prompt + screen.expect(end.is_shown, bool, description="the run's question") + assert len(screen.shown_windows()) == 1 + + end.cancel() + + screen.expect(end.is_shown, operator.not_, description="the run's question answered") + assert screen.shown_windows() == () + + +class TestARunEndingBehindDisplaySettings: + """A run that ends while Display settings is open asks its question once the dialog is answered.""" + + def test_the_question_waits_for_an_unchanged_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """With Display settings left unchanged, the run's question comes after Cancel closes the dialog.""" + settings = screen.display_settings + + def open_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + + settings.open() + + screen.expect(settings.is_shown, bool, description="Display settings") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + open_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_hands_the_screen_over, + ).run() + + def test_the_discard_question_comes_before_the_runs(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """With a changed setting, Cancel asks about discarding the change first, and the run's question comes + after Discard. + """ + settings = screen.display_settings + discard = settings.discard_prompt + + def change_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original = settings.vsync() + + settings.toggle_vsync() + + screen.expect(settings.vsync, original.__ne__, description="the box flipped") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_asks_about_the_change_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(discard.is_shown, bool, description="the discard question") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def discard_hands_the_screen_over(screen: Screen) -> None: + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + assert not settings.is_shown() + the_end_comes_and_closes(screen) + + screen.scenario( + change_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_asks_about_the_change_first, + discard_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheWindowModeCountdown: + """A run that ends while the window-mode countdown shows waits for the countdown and for the dialog behind + it. + """ + + def test_the_question_waits_for_keep_and_the_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Keep brings the dialog back, and the run's question comes after the change is discarded.""" + settings = screen.display_settings + discard = settings.discard_prompt + original: List[bool] = [] + + def change_the_window_mode_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original.append(settings.borderless()) + + settings.toggle_borderless() + + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def keep_brings_the_dialog_back_first(screen: Screen) -> None: + settings.keep() + + screen.expect(settings.is_shown, bool, description="Display settings back") + screen.frames(SETTLING_FRAMES) + assert not settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + assert settings.borderless() != original[0] + + def discarding_the_change_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + screen.expect(discard.is_shown, bool, description="the discard question") + + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + the_end_comes_and_closes(screen) + + screen.scenario( + change_the_window_mode_over_a_held_run, + the_run_ends_and_its_question_waits, + keep_brings_the_dialog_back_first, + discarding_the_change_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheReassignQuestion: + """A run that ends while Keyboard settings asks to reassign keys waits for the question and for the dialog + behind it. + """ + + def test_the_question_waits_for_both(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Cancel brings the dialog back, and the run's question comes after the dialog closes.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + keys: Dict[ShortcutId, str] = {} + + def ask_to_reassign_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + keys.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert reassign.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_brings_the_dialog_back_first(screen: Screen) -> None: + reassign.cancel() + + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] + + def closing_the_dialog_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + ask_to_reassign_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_brings_the_dialog_back_first, + closing_the_dialog_hands_the_screen_over, + ).run() + + +class TestTheRowListeningAfterTheReassignQuestion: + """A row that reads as listening after Cancel answers the reassign question takes the next keys pressed. + + Redo listens, Undo's keys ask to reassign, and Cancel brings the dialog back with the row still + listening. The same keys then ask again. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a row listening again after the reassign question takes no keys", + ) + def test_the_same_keys_ask_again(self, screen: Screen) -> None: + """The same keys pressed again ask the reassign question once more.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.REDO) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning again") diff --git a/tests/screens/prompts/modals/test_save_into_a_closed_folder.py b/tests/screens/prompts/modals/test_save_into_a_closed_folder.py new file mode 100644 index 000000000..babcf86a5 --- /dev/null +++ b/tests/screens/prompts/modals/test_save_into_a_closed_folder.py @@ -0,0 +1,93 @@ +import operator +import stat +from typing import Final + +import pytest + +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.dialogs import DialogKind +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open, marked, remove_from_the_browser, titled +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" +LOCKED_FOLDER: Final[str] = "Locked" +READ_AND_ENTER: Final[int] = stat.S_IRUSR | stat.S_IXUSR +SAVE_FAILED: Final[str] = "global.dialog.message.reconstruction_save_failed" + + +class TestASaveIntoAFolderThatTakesNothing: + """A save into a folder that accepts no file shows its failure alone, and the reconstruction stays open. + + The open reconstruction loses its file and a read-only folder is made. Close asks, Save into the folder + fails, and the failure shows alone. Dismissing it keeps the reconstruction open, and a save into a + writable folder closes it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_stands_alone_and_a_save_elsewhere_closes_it(self, screen: Screen) -> None: + """The failure shows alone, and a save elsewhere writes the file and closes the reconstruction.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + locked = RECONSTRUCTIONS_DIRECTORY / LOCKED_FOLDER + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + locked.mkdir() + locked.chmod(READ_AND_ENTER) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + + def save_into_the_locked_folder(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing") + screen.answer_next_dialog(DialogKind.SAVE, locked / KEPT) + + prompt.save() + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(PermissionError.__name__) + assert screen.words(SAVE_FAILED) in notice.words() + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def dismissing_it_leaves_the_reconstruction_open(screen: Screen) -> None: + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the failure dismissed") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert not (locked / KEPT).exists() + + def a_save_elsewhere_closes_it(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + + screen.scenario( + remove_its_file, + save_into_the_locked_folder, + dismissing_it_leaves_the_reconstruction_open, + a_save_elsewhere_closes_it, + ).run() diff --git a/tests/screens/prompts/open_voice/__init__.py b/tests/screens/prompts/open_voice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/test_open_voice.py b/tests/screens/prompts/open_voice/test_open_voice.py similarity index 85% rename from tests/screens/prompts/test_open_voice.py rename to tests/screens/prompts/open_voice/test_open_voice.py index 9df7cda98..80e3ec6cc 100644 --- a/tests/screens/prompts/test_open_voice.py +++ b/tests/screens/prompts/open_voice/test_open_voice.py @@ -6,7 +6,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.application import Startup +from tests.suite.screens.application.startup import Startup from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import ( edit_envelope, @@ -18,7 +18,7 @@ ) from tests.suite.screens.steps.sequencer import double_click_voice, open_voice, voice_row from tests.suite.screens.vocabulary.dialogs import CANCEL, DISCARD, EDIT_MESSAGE, EDIT_TITLE, SAVE -from tests.suite.screens.world import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE SAMPLE_ORDINAL: Final[int] = 0 PERSONS_HOLD_FRAMES: Final[int] = 10 @@ -28,11 +28,12 @@ def sample_title(screen: Screen, *, unsaved: bool) -> str: + """The window title while the project's sample is open, with or without unsaved changes.""" return voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=unsaved) def leave_without_saving(screen: Screen) -> None: - """Exits, letting the project's changes go at the question the exit asks.""" + """Exits and discards the project's changes at the question the exit asks.""" prompt = screen.project.unsaved_prompt screen.press_shortcut(ShortcutId.EXIT) screen.expect(prompt.is_shown, bool, description="the question about the project") @@ -43,6 +44,7 @@ def leave_without_saving(screen: Screen) -> None: def open_the_sample(screen: Screen) -> None: + """Opens the project's sample from the Sequencer and waits for it to show in front.""" open_voice(screen, SONG_SAMPLE) screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") @@ -50,10 +52,16 @@ def open_the_sample(screen: Screen) -> None: class TestAVoiceDoubleClickedOverAnEditedReconstruction: - """A voice double-clicked while an edited reconstruction of its own stands open asks about the edits first.""" + """A voice double-clicked over an edited reconstruction of its own asks about the edits first. + + The scenario edits the open reconstruction and double-clicks a sample. Cancel keeps the + reconstruction and the Sequencer in front. After a second double-click, Discard opens the sample + and keeps the reconstruction's file as it was. + """ @pytest.fixture def startup(self) -> Startup: + """Starts with the reconstruction open beside the song.""" return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) def test_the_question_comes_first_and_cancel_keeps_the_reconstruction(self, screen: Screen) -> None: @@ -107,9 +115,14 @@ class TestTheOpenVoiceGoesWithItsProject: @pytest.fixture def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" return Startup(reconstruction=None, project=SONG) def test_removing_the_open_voice_empties_the_tab(self, screen: Screen) -> None: + """Removing the open sample from the voices list leaves the project alone in the title. + + The file line of the Reconstructions tab ends empty. + """ voices = screen.sequencer.voices menu = screen.context_menu @@ -135,6 +148,8 @@ def the_tab_empties(screen: Screen) -> None: screen.scenario(remove_it, the_tab_empties, leave_without_saving).run() def test_closing_the_project_empties_the_tab(self, screen: Screen) -> None: + """Closing the project leaves nothing open, no voices and an empty file line.""" + def close_the_project(screen: Screen) -> None: open_the_sample(screen) @@ -152,9 +167,14 @@ class TestTheTabFollowsTheProjectsHistory: @pytest.fixture def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" return Startup(reconstruction=None, project=SONG) def test_undo_and_redo_of_a_sample_edit(self, screen: Screen) -> None: + """Undo restores the sample's volume as it stood. + + Redo brings the edit back with its unsaved title. + """ reconstructions = screen.reconstructions drawn: Dict[str, str] = {} @@ -188,6 +208,7 @@ def redo_from_the_sequencer(screen: Screen) -> None: screen.scenario(edit_the_sample, undo_from_the_sequencer, redo_from_the_sequencer, leave_without_saving).run() def test_undo_of_an_instrument_edit_redraws_its_envelopes(self, screen: Screen) -> None: + """Undo restores the instrument's volume as it stood and the project's title shows no unsaved mark.""" reconstructions = screen.reconstructions drawn: Dict[str, str] = {} @@ -224,6 +245,7 @@ class TestAVoiceOpenedByADoubleClickHeldAsAPersonHoldsIt: @pytest.fixture def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" return Startup(reconstruction=None, project=SONG) @pytest.mark.xfail( @@ -232,6 +254,10 @@ def startup(self) -> Startup: reason="bugs-and-todos ยง Bugs: a voice double-clicked with the second press held opens edited", ) def test_the_voice_opens_unchanged(self, screen: Screen) -> None: + """The volume drawn after a held double-click equals the volume drawn after a plain open. + + The title shows the sample without an unsaved mark. + """ reconstructions = screen.reconstructions drawn: List[Tuple[str, ...]] = [] diff --git a/tests/screens/prompts/test_every_door.py b/tests/screens/prompts/test_every_door.py deleted file mode 100644 index eb8c95ad3..000000000 --- a/tests/screens/prompts/test_every_door.py +++ /dev/null @@ -1,590 +0,0 @@ -import operator -from dataclasses import dataclass -from enum import StrEnum -from functools import partial -from typing import Callable, Final, List, Tuple - -import pytest - -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.main import convert_alone, home_path -from tests.suite.screens.steps.reconstructions import ( - converted, - edit_envelope, - expect_open, - load_from_the_browser, - marked, - raise_the_first_level, - remove_from_the_browser, - titled, - voice_title, -) -from tests.suite.screens.steps.sequencer import double_click_voice, open_voice -from tests.suite.screens.vocabulary.dialogs import ( - CANCEL, - CLOSE, - CLOSE_MESSAGE, - CLOSE_TITLE, - DISCARD, - EDIT_MESSAGE, - EDIT_TITLE, - EXIT_PROJECT_MESSAGE, - EXIT_RECONSTRUCTION_MESSAGE, - LOAD_MESSAGE, - LOAD_TITLE, - SAVE, -) -from tests.suite.screens.world import ( - BASS, - LEAD, - OPEN_RECONSTRUCTION, - OTHER_RECONSTRUCTION, - SONG, - SONG_INSTRUMENT, - SONG_SAMPLE, -) - -SETTLING_FRAMES: Final[int] = 10 -SAMPLE_ORDINAL: Final[int] = 0 -FADING: Final[str] = "12 8 4" -EXIT_TITLE: Final[str] = "global.dialog.title.exit_confirmation" -EXIT: Final[str] = "global.dialog.label.exit" - - -class Door(StrEnum): - """A gesture that puts the open document away, to replace it or to leave. - - A voice opens from a double-click on its row and from Edit on its row's menu. - """ - - BROWSER = "browser" - MENU_OPEN = "menu_open" - CLOSE = "close" - VOICE = "voice" - VOICE_MENU = "voice_menu" - CONVERSION_LOAD = "conversion_load" - EXIT = "exit" - WINDOW_CLOSE = "window_close" - - -ASKING_DOORS: Final[Tuple[Door, ...]] = tuple(Door) - - -@dataclass(frozen=True) -class Question: - """A question as the reader meets it: its title, its words and the answers it offers.""" - - title: str - words: str - answers: Tuple[str, ...] - - -@dataclass(frozen=True) -class Standing: - """What the screen shows of the open document, which a question taken back leaves as it was.""" - - title: str - file_line: str - volume: str - windows: Tuple[str, ...] - dialogs: int - - -def question_at(screen: Screen, door: Door) -> Question: - """The question ``door`` asks of a standalone reconstruction with unsaved changes, as the language file words it.""" - match door: - case Door.BROWSER | Door.MENU_OPEN | Door.CONVERSION_LOAD: - return Question( - screen.words(LOAD_TITLE), - screen.words(LOAD_MESSAGE), - (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), - ) - case Door.CLOSE: - return Question( - screen.words(CLOSE_TITLE), - screen.words(CLOSE_MESSAGE), - (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)), - ) - case Door.VOICE | Door.VOICE_MENU: - return Question( - screen.words(EDIT_TITLE), - screen.words(EDIT_MESSAGE), - (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), - ) - case Door.EXIT | Door.WINDOW_CLOSE: - return Question( - screen.words(EXIT_TITLE), - screen.words(EXIT_RECONSTRUCTION_MESSAGE), - (screen.words(SAVE), screen.words(EXIT), screen.words(CANCEL)), - ) - - -def knock(screen: Screen, door: Door, *, voice: str) -> None: - """Makes the gesture ``door`` stands for; a door that loads loads Other.stn, the run's bass, or ``voice``.""" - match door: - case Door.BROWSER: - load_from_the_browser(screen, OTHER_RECONSTRUCTION) - case Door.MENU_OPEN: - screen.reconstructions.open_from_menu() - case Door.CLOSE: - screen.reconstructions.close_from_menu() - case Door.VOICE: - double_click_voice(screen, voice) - case Door.VOICE_MENU: - open_voice(screen, voice) - case Door.CONVERSION_LOAD: - convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) - screen.main.converter.end_prompt.confirm() - screen.expect(screen.main.converter.end_prompt.is_shown, operator.not_, description="the end answered") - case Door.EXIT: - screen.press_shortcut(ShortcutId.EXIT) - case Door.WINDOW_CLOSE: - screen.close_window() - - -def standing(screen: Screen) -> Standing: - reconstructions = screen.reconstructions - return Standing( - title=screen.title(), - file_line=reconstructions.file_line(), - volume=reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), - windows=tuple(window.alias for window in screen.shown_windows()), - dialogs=len(screen.dialog_requests()), - ) - - -def asked(screen: Screen, door: Door) -> Question: - prompt = screen.reconstructions.unsaved_prompt - screen.expect(prompt.is_shown, bool, description=f"the question {door} asks") - return Question(prompt.title(), prompt.words(), prompt.answers()) - - -def asks_and_cancel_keeps_it(door: Door, *, voice: str) -> Callable[[Screen], None]: - """The step knocking at ``door``, which asks its question, then taking it back, which leaves all standing.""" - - def step(screen: Screen) -> None: - prompt = screen.reconstructions.unsaved_prompt - before = standing(screen) - - knock(screen, door, voice=voice) - - assert asked(screen, door) == question_at(screen, door) - prompt.cancel() - screen.expect(prompt.is_shown, operator.not_, description="the question taken back") - screen.frames(SETTLING_FRAMES) - assert screen.is_running() - assert standing(screen) == before - - step.__name__ = f"{door.value}_asks_and_cancel_keeps_it" - return step - - -def leave_letting_it_go(screen: Screen) -> None: - """Exits, answering Exit to the question about the reconstruction.""" - prompt = screen.reconstructions.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the reconstruction") - - prompt.confirm() - - assert screen.wait_for_exit() - - -def leave_letting_the_project_go(screen: Screen) -> None: - """Exits, answering Exit to the question about the project.""" - prompt = screen.project.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the project") - - prompt.confirm() - - assert screen.wait_for_exit() - - -def nothing_asked(screen: Screen) -> None: - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - - -def expect_title(screen: Screen, title: str) -> None: - screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") - - -class TestAnEditedReconstructionAtEveryDoor: - """Every door asks about an edited reconstruction in its own words, and Cancel leaves everything standing.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) - - def test_each_door_asks_its_question(self, screen: Screen) -> None: - def edit_it(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - raise_the_first_level( - screen, - ChannelName.PULSE1, - title=titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)), - ) - - screen.scenario( - edit_it, - *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), - leave_letting_it_go, - ).run() - - -class TestAReconstructionWhoseFileWasRemovedAtEveryDoor: - """Every door asks about a reconstruction whose file was removed, as it asks about an edited one.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) - - def test_each_door_asks_its_question(self, screen: Screen) -> None: - def remove_its_file(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - remove_from_the_browser(screen, OPEN_RECONSTRUCTION) - - expect_title(screen, titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True))) - - screen.scenario( - remove_its_file, - *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), - leave_letting_it_go, - ).run() - - -class TestAnEditedSampleAtEveryDoor: - """An edited sample of the project is put away without a question, its edits kept in the project. - - The exit asks about the project alone. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_no_door_asks_and_the_exit_asks_about_the_project(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - edit: List[str] = [] - sample_open = partial(voice_title, screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True) - - def open_the_sample(screen: Screen) -> None: - open_voice(screen, SONG_SAMPLE) - expect_title(screen, sample_open()) - assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] - - def edit_the_sample(screen: Screen) -> None: - open_voice(screen, SONG_SAMPLE) - expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) - - edit.append(raise_the_first_level(screen, ChannelName.PULSE1, title=sample_open())) - - def the_browser_loads_at_once(screen: Screen) -> None: - knock(screen, Door.BROWSER, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) - nothing_asked(screen) - open_the_sample(screen) - - def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) - - knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) - nothing_asked(screen) - open_the_sample(screen) - - def another_voice_opens_at_once(screen: Screen) -> None: - knock(screen, Door.VOICE_MENU, voice=SONG_INSTRUMENT) - - screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") - assert screen.title() == titled(screen, marked(SONG.stem, unsaved=True)) - nothing_asked(screen) - open_the_sample(screen) - - def close_closes_at_once(screen: Screen) -> None: - knock(screen, Door.CLOSE, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True))) - assert reconstructions.file_line() == "" - nothing_asked(screen) - open_the_sample(screen) - - def the_conversion_loads_at_once(screen: Screen) -> None: - knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) - nothing_asked(screen) - open_the_sample(screen) - - def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: - def step(screen: Screen) -> None: - prompt = screen.project.unsaved_prompt - - knock(screen, door, voice=SONG_SAMPLE) - - screen.expect(prompt.is_shown, bool, description="the question about the project") - assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) - assert not reconstructions.unsaved_prompt.is_shown() - prompt.cancel() - screen.expect(prompt.is_shown, operator.not_, description="the question taken back") - screen.frames(SETTLING_FRAMES) - assert screen.title() == sample_open() - - step.__name__ = f"{door.value}_asks_about_the_project" - return step - - screen.scenario( - edit_the_sample, - the_browser_loads_at_once, - the_menu_asks_for_a_file_at_once, - another_voice_opens_at_once, - close_closes_at_once, - the_conversion_loads_at_once, - leaving_asks_about_the_project(Door.EXIT), - leaving_asks_about_the_project(Door.WINDOW_CLOSE), - leave_letting_the_project_go, - ).run() - - -class TestAnInstrumentAtEveryDoor: - """An instrument is put away without a question, Close has nothing to close, and the exit asks about the project.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_no_door_asks_and_close_stands_aside(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - edited = titled(screen, marked(SONG.stem, unsaved=True)) - - def open_the_instrument(screen: Screen) -> None: - open_voice(screen, SONG_INSTRUMENT) - screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") - screen.expect( - lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), - FADING.__eq__, - description="its edit drawn", - ) - - def edit_the_instrument(screen: Screen) -> None: - open_voice(screen, SONG_INSTRUMENT) - screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") - - edit_envelope(screen, channel=ChannelName.PULSE1, feature=FeatureKey.VOLUME, sequence=FADING, title=edited) - - def the_browser_loads_at_once(screen: Screen) -> None: - knock(screen, Door.BROWSER, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) - assert not reconstructions.instruments.offers_audition() - nothing_asked(screen) - open_the_instrument(screen) - - def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) - - knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) - nothing_asked(screen) - open_the_instrument(screen) - - def a_sample_opens_at_once(screen: Screen) -> None: - knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) - - expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True)) - assert not reconstructions.instruments.offers_audition() - nothing_asked(screen) - open_the_instrument(screen) - - def close_has_nothing_to_close(screen: Screen) -> None: - assert not reconstructions.can_close() - - screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) - - nothing_asked(screen) - assert reconstructions.instruments.offers_audition() - assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING - assert screen.title() == edited - - def the_conversion_loads_at_once(screen: Screen) -> None: - knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) - nothing_asked(screen) - open_the_instrument(screen) - - def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: - def step(screen: Screen) -> None: - prompt = screen.project.unsaved_prompt - - knock(screen, door, voice=SONG_SAMPLE) - - screen.expect(prompt.is_shown, bool, description="the question about the project") - assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) - prompt.cancel() - screen.expect(prompt.is_shown, operator.not_, description="the question taken back") - screen.frames(SETTLING_FRAMES) - assert reconstructions.instruments.offers_audition() - - step.__name__ = f"{door.value}_asks_about_the_project" - return step - - screen.scenario( - edit_the_instrument, - the_browser_loads_at_once, - the_menu_asks_for_a_file_at_once, - a_sample_opens_at_once, - close_has_nothing_to_close, - the_conversion_loads_at_once, - leaving_asks_about_the_project(Door.EXIT), - leaving_asks_about_the_project(Door.WINDOW_CLOSE), - leave_letting_the_project_go, - ).run() - - -class TestASavedReconstructionAtEveryDoor: - """A reconstruction with nothing unsaved is put away at every door without a question, and leaving leaves.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) - - def test_no_door_asks(self, screen: Screen) -> None: - def the_browser_loads_at_once(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - knock(screen, Door.BROWSER, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) - nothing_asked(screen) - - def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.OPEN, OPEN_RECONSTRUCTION) - - knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem, OPEN_RECONSTRUCTION.name)) - nothing_asked(screen) - - def the_conversion_loads_at_once(screen: Screen) -> None: - knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) - nothing_asked(screen) - - def a_voice_opens_at_once(screen: Screen) -> None: - knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) - - expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) - nothing_asked(screen) - - def close_closes_at_once(screen: Screen) -> None: - load_from_the_browser(screen, OPEN_RECONSTRUCTION) - expect_open(screen, OPEN_RECONSTRUCTION) - - knock(screen, Door.CLOSE, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem)) - nothing_asked(screen) - - def the_window_closes_at_once(screen: Screen) -> None: - load_from_the_browser(screen, OPEN_RECONSTRUCTION) - expect_open(screen, OPEN_RECONSTRUCTION) - - knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) - - assert screen.wait_for_exit() - - screen.scenario( - the_browser_loads_at_once, - the_menu_asks_for_a_file_at_once, - the_conversion_loads_at_once, - a_voice_opens_at_once, - close_closes_at_once, - the_window_closes_at_once, - ).run() - - def test_exit_leaves_at_once(self, screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - knock(screen, Door.EXIT, voice=SONG_SAMPLE) - - assert screen.wait_for_exit() - - -class TestNothingOpenAtEveryDoor: - """With nothing open, every door that loads loads at once, and Close has nothing to close.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_each_door_loads_at_once(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - empty = titled(screen, SONG.stem) - - def close_it_again(screen: Screen) -> None: - reconstructions.close_from_menu() - expect_title(screen, empty) - - def close_has_nothing_to_close(screen: Screen) -> None: - assert not reconstructions.can_close() - - screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) - - nothing_asked(screen) - assert screen.title() == empty - - def the_browser_loads_at_once(screen: Screen) -> None: - knock(screen, Door.BROWSER, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) - nothing_asked(screen) - close_it_again(screen) - - def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: - screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) - - knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) - nothing_asked(screen) - close_it_again(screen) - - def a_voice_opens_at_once(screen: Screen) -> None: - knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) - - expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) - nothing_asked(screen) - close_it_again(screen) - - def the_conversion_loads_at_once(screen: Screen) -> None: - knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) - - expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) - nothing_asked(screen) - - screen.scenario( - close_has_nothing_to_close, - the_browser_loads_at_once, - the_menu_asks_for_a_file_at_once, - a_voice_opens_at_once, - the_conversion_loads_at_once, - ).run() - - def test_the_window_closes_at_once(self, screen: Screen) -> None: - screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the song open") - - knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) - - assert screen.wait_for_exit() diff --git a/tests/screens/prompts/test_keys.py b/tests/screens/prompts/test_keys.py deleted file mode 100644 index c13e065b3..000000000 --- a/tests/screens/prompts/test_keys.py +++ /dev/null @@ -1,291 +0,0 @@ -import operator -from typing import Final, List - -import pytest - -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName -from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.main import convert_alone, home_path -from tests.suite.screens.steps.project import retitle_project, saved_project_title -from tests.suite.screens.steps.reconstructions import ( - converted, - expect_open, - first_level_raised, - leading, - marked, - raise_the_first_level, - remove_from_the_browser, - stored_levels, - titled, -) -from tests.suite.screens.vocabulary.dialogs import CLOSE_MESSAGE, EXIT_PROJECT_MESSAGE, EXIT_RECONSTRUCTION_MESSAGE -from tests.suite.screens.world import BASS, LEAD, OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE - -RETITLED: Final[str] = "Retitled from the keyboard" -KEPT: Final[str] = "Kept.stn" -SETTLING_FRAMES: Final[int] = 10 - - -def tab(screen: Screen, times: int) -> None: - for _ in range(times): - screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) - - -def enter(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) - - -def escape(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.DIALOG_CANCEL) - - -def edited_title(screen: Screen) -> str: - return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - - -class TestTheExitChainFromTheKeyboard: - """Tab, Enter and Escape answer the question about the reconstruction that the project's answer opens. - - Each question starts on Cancel, so one Tab reaches Save and two reach Exit. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_escape_on_the_second_keeps_everything_and_the_keys_then_save_and_leave(self, screen: Screen) -> None: - project_prompt = screen.project.unsaved_prompt - reconstruction_prompt = screen.reconstructions.unsaved_prompt - stored: List[bytes] = [] - - def open_the_project_and_change_both(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - stored.append(OPEN_RECONSTRUCTION.read_bytes()) - screen.answer_next_dialog(DialogKind.OPEN, SONG) - screen.project.open() - screen.expect( - screen.sequencer.voices.names, [SONG_SAMPLE, SONG_INSTRUMENT].__eq__, description="the song's voices" - ) - retitle_project(screen, RETITLED) - - raise_the_first_level( - screen, - ChannelName.PULSE1, - title=titled(screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True)), - ) - - def exit_asks_about_the_project(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - - screen.expect(project_prompt.is_shown, bool, description="the question about the project") - assert project_prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) - - def exit_on_the_project_asks_about_the_reconstruction(screen: Screen) -> None: - tab(screen, 2) - enter(screen) - - screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") - assert reconstruction_prompt.words() == screen.words(EXIT_RECONSTRUCTION_MESSAGE) - assert not project_prompt.is_shown() - - def escape_keeps_the_application_and_both_changes(screen: Screen) -> None: - escape(screen) - - screen.expect(reconstruction_prompt.is_shown, operator.not_, description="the question gone") - screen.frames(SETTLING_FRAMES) - assert screen.is_running() - assert screen.shown_windows() == () - assert screen.title() == titled( - screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True) - ) - assert saved_project_title(SONG) != RETITLED - assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] - - def the_keys_save_the_project_and_leave(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(project_prompt.is_shown, bool, description="the question about the project again") - tab(screen, 1) - enter(screen) - screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") - - tab(screen, 2) - enter(screen) - - assert screen.wait_for_exit() - assert saved_project_title(SONG) == RETITLED - assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] - - screen.scenario( - open_the_project_and_change_both, - exit_asks_about_the_project, - exit_on_the_project_asks_about_the_reconstruction, - escape_keeps_the_application_and_both_changes, - the_keys_save_the_project_and_leave, - ).run() - - -class TestTheLoadChainFromTheKeyboard: - """The keys answer Load at a run's end, and the question about unsaved changes that Load opens.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_escape_keeps_the_edits_and_the_keys_then_save_and_load(self, screen: Screen) -> None: - converter = screen.main.converter - prompt = screen.reconstructions.unsaved_prompt - standing: List[int] = [] - - def edit_the_open_reconstruction(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) - - raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen)) - - def load_from_the_keyboard(screen: Screen, recording: str) -> None: - convert_alone( - screen, - home_path(recording), - channel=ChannelName.PULSE1, - replacing=[home_path(BASS), home_path(LEAD)], - ) - tab(screen, 1) - enter(screen) - - screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") - assert not converter.end_prompt.is_shown() - - def escape_keeps_the_edited_one(screen: Screen) -> None: - load_from_the_keyboard(screen, BASS) - - escape(screen) - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - assert screen.title() == edited_title(screen) - assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) - - def save_and_load_from_the_keyboard(screen: Screen) -> None: - load_from_the_keyboard(screen, LEAD) - - tab(screen, 1) - enter(screen) - - expect_open(screen, converted(home_path(LEAD))) - saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) - assert leading(saved, standing) == first_level_raised(standing) - - screen.scenario( - edit_the_open_reconstruction, - escape_keeps_the_edited_one, - save_and_load_from_the_keyboard, - ).run() - - -class TestASaveThatAsksWhereFromTheKeyboard: - """The question a dismissed save dialog brings back answers to the keys like the first one.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_escape_on_the_returned_question_then_the_keys_save_where_asked(self, screen: Screen) -> None: - prompt = screen.reconstructions.unsaved_prompt - kept = RECONSTRUCTIONS_DIRECTORY / KEPT - - def take_its_file_away(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - remove_from_the_browser(screen, OPEN_RECONSTRUCTION) - - screen.expect(screen.title, edited_title(screen).__eq__, description="the reconstruction left unsaved") - - def save_asks_where_and_a_dismissed_dialog_asks_again(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) - screen.expect(prompt.is_shown, bool, description="the question about closing") - assert prompt.words() == screen.words(CLOSE_MESSAGE) - screen.answer_next_dialog(DialogKind.SAVE, None) - - tab(screen, 1) - enter(screen) - - screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") - screen.expect(prompt.is_shown, bool, description="the question back") - assert len(prompt.shown_windows()) == 1 - - def escape_keeps_it_open(screen: Screen) -> None: - escape(screen) - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - screen.frames(SETTLING_FRAMES) - assert screen.title() == edited_title(screen) - assert screen.shown_windows() == () - - def the_keys_save_it_where_asked_and_close_it(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) - screen.expect(prompt.is_shown, bool, description="the question about closing again") - screen.answer_next_dialog(DialogKind.SAVE, kept) - - tab(screen, 1) - enter(screen) - - screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") - assert kept.exists() - assert not prompt.is_shown() - - screen.scenario( - take_its_file_away, - save_asks_where_and_a_dismissed_dialog_asks_again, - escape_keeps_it_open, - the_keys_save_it_where_asked_and_close_it, - ).run() - - -class TestTheDisplayQuestionFromTheKeyboard: - """The question Escape asks of changed Display settings answers to the keys, as does the dialog after it.""" - - def test_enter_keeps_editing_and_tab_enter_discards(self, screen: Screen) -> None: - settings = screen.display_settings - prompt = settings.discard_prompt - original: List[bool] = [] - - def change_vertical_sync(screen: Screen) -> None: - settings.open() - screen.expect(settings.is_shown, bool, description="the Display settings dialog") - original.append(settings.vsync()) - - settings.toggle_vsync() - - screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") - - def escape_asks(screen: Screen) -> None: - escape(screen) - - screen.expect(prompt.is_shown, bool, description="the discard question") - - def enter_keeps_editing(screen: Screen) -> None: - enter(screen) - - screen.expect(settings.is_shown, bool, description="the dialog back") - assert not prompt.is_shown() - assert settings.vsync() != original[0] - - def escape_then_tab_and_enter_discard(screen: Screen) -> None: - escape(screen) - screen.expect(prompt.is_shown, bool, description="the discard question again") - - tab(screen, 1) - enter(screen) - - screen.expect(prompt.is_shown, operator.not_, description="the question gone") - assert not settings.is_shown() - settings.open() - screen.expect(settings.is_shown, bool, description="the dialog opened again") - assert settings.vsync() == original[0] - settings.cancel() - screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed") - - screen.scenario(change_vertical_sync, escape_asks, enter_keeps_editing, escape_then_tab_and_enter_discard).run() diff --git a/tests/screens/prompts/test_modal_over_modal.py b/tests/screens/prompts/test_modal_over_modal.py deleted file mode 100644 index 3fc7cd82b..000000000 --- a/tests/screens/prompts/test_modal_over_modal.py +++ /dev/null @@ -1,490 +0,0 @@ -import operator -import stat -from typing import Dict, Final, List - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.application import Startup -from tests.suite.screens.boundaries.dialogs import DialogKind -from tests.suite.screens.holds import ConversionHold, RegenerationHold -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.main import RUN_TIMEOUT_SECONDS, gather, home_path -from tests.suite.screens.steps.reconstructions import ( - expect_open, - load_from_the_browser, - marked, - raise_the_first_level_while_held, - remove_from_the_browser, - titled, -) -from tests.suite.screens.vocabulary.converter import CONVERT_ONE -from tests.suite.screens.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE -from tests.suite.screens.world import BASS, OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION - -SETTLING_FRAMES: Final[int] = 30 -KEPT: Final[str] = "Kept.stn" -LOCKED_FOLDER: Final[str] = "Locked" -READ_AND_ENTER: Final[int] = stat.S_IRUSR | stat.S_IXUSR -SAVE_FAILED: Final[str] = "global.dialog.message.reconstruction_save_failed" -REBUILD_FAILURE: Final[str] = "The rebuild broke on its worker" -LISTENING: Final[str] = "settings.keybindings.message.capturing" - - -class RebuildBrokeError(RuntimeError): - """The failure a held rebuild is let go as, standing for a rebuild that raised.""" - - -def start_a_held_run(screen: Screen) -> None: - """Converts the home's bass, which the hold keeps running halfway.""" - converter = screen.main.converter - screen.tabs.bring_to_front(Tab.MAIN) - gather(screen, home_path(BASS)) - - converter.press_action() - - screen.bridge.expect( - converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS - ) - - -def let_the_run_end(screen: Screen, hold: ConversionHold) -> None: - """Lets the held run go, and waits for it to be over while its question waits its turn.""" - converter = screen.main.converter - hold.release() - - screen.bridge.expect( - converter.action, - screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, - description="the run over", - timeout=RUN_TIMEOUT_SECONDS, - ) - screen.frames(SETTLING_FRAMES) - - -def the_end_comes_and_closes(screen: Screen) -> None: - """The run's question comes once the screen is free, and Close puts it away.""" - end = screen.main.converter.end_prompt - screen.expect(end.is_shown, bool, description="the run's question") - assert len(screen.shown_windows()) == 1 - - end.cancel() - - screen.expect(end.is_shown, operator.not_, description="the run's question answered") - assert screen.shown_windows() == () - - -class TestARunEndingBehindDisplaySettings: - """A run that ends while Display settings stands asks its question once the dialog is answered.""" - - def test_the_question_waits_for_an_unchanged_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: - settings = screen.display_settings - - def open_display_settings_over_a_held_run(screen: Screen) -> None: - start_a_held_run(screen) - - settings.open() - - screen.expect(settings.is_shown, bool, description="Display settings") - - def the_run_ends_and_its_question_waits(screen: Screen) -> None: - let_the_run_end(screen, conversion_hold) - - assert settings.is_shown() - assert not screen.main.converter.end_prompt.is_shown() - - def cancel_hands_the_screen_over(screen: Screen) -> None: - settings.cancel() - - screen.expect(settings.is_shown, operator.not_, description="Display settings closed") - the_end_comes_and_closes(screen) - - screen.scenario( - open_display_settings_over_a_held_run, - the_run_ends_and_its_question_waits, - cancel_hands_the_screen_over, - ).run() - - def test_the_discard_question_comes_before_the_runs(self, screen: Screen, conversion_hold: ConversionHold) -> None: - settings = screen.display_settings - discard = settings.discard_prompt - - def change_display_settings_over_a_held_run(screen: Screen) -> None: - start_a_held_run(screen) - settings.open() - screen.expect(settings.is_shown, bool, description="Display settings") - original = settings.vsync() - - settings.toggle_vsync() - - screen.expect(settings.vsync, original.__ne__, description="the box flipped") - - def the_run_ends_and_its_question_waits(screen: Screen) -> None: - let_the_run_end(screen, conversion_hold) - - assert settings.is_shown() - assert not screen.main.converter.end_prompt.is_shown() - - def cancel_asks_about_the_change_first(screen: Screen) -> None: - settings.cancel() - - screen.expect(discard.is_shown, bool, description="the discard question") - screen.frames(SETTLING_FRAMES) - assert not screen.main.converter.end_prompt.is_shown() - assert len(screen.shown_windows()) == 1 - - def discard_hands_the_screen_over(screen: Screen) -> None: - discard.confirm() - - screen.expect(discard.is_shown, operator.not_, description="the discard question answered") - assert not settings.is_shown() - the_end_comes_and_closes(screen) - - screen.scenario( - change_display_settings_over_a_held_run, - the_run_ends_and_its_question_waits, - cancel_asks_about_the_change_first, - discard_hands_the_screen_over, - ).run() - - -class TestARunEndingBehindTheWindowModeCountdown: - """A run that ends while the window-mode countdown stands waits for the countdown and the dialog behind it.""" - - def test_the_question_waits_for_keep_and_the_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: - settings = screen.display_settings - discard = settings.discard_prompt - original: List[bool] = [] - - def change_the_window_mode_over_a_held_run(screen: Screen) -> None: - start_a_held_run(screen) - settings.open() - screen.expect(settings.is_shown, bool, description="Display settings") - original.append(settings.borderless()) - - settings.toggle_borderless() - - screen.expect(settings.countdown_shown, bool, description="the countdown") - assert not settings.is_shown() - - def the_run_ends_and_its_question_waits(screen: Screen) -> None: - let_the_run_end(screen, conversion_hold) - - assert settings.countdown_shown() - assert not screen.main.converter.end_prompt.is_shown() - - def keep_brings_the_dialog_back_first(screen: Screen) -> None: - settings.keep() - - screen.expect(settings.is_shown, bool, description="Display settings back") - screen.frames(SETTLING_FRAMES) - assert not settings.countdown_shown() - assert not screen.main.converter.end_prompt.is_shown() - assert settings.borderless() != original[0] - - def discarding_the_change_hands_the_screen_over(screen: Screen) -> None: - settings.cancel() - screen.expect(discard.is_shown, bool, description="the discard question") - - discard.confirm() - - screen.expect(discard.is_shown, operator.not_, description="the discard question answered") - the_end_comes_and_closes(screen) - - screen.scenario( - change_the_window_mode_over_a_held_run, - the_run_ends_and_its_question_waits, - keep_brings_the_dialog_back_first, - discarding_the_change_hands_the_screen_over, - ).run() - - -class TestARunEndingBehindTheReassignQuestion: - """A run that ends while Keyboard settings asks to reassign keys waits for the question and the dialog behind it.""" - - def test_the_question_waits_for_both(self, screen: Screen, conversion_hold: ConversionHold) -> None: - settings = screen.keyboard_settings - reassign = settings.reassign_prompt - keys: Dict[ShortcutId, str] = {} - - def ask_to_reassign_over_a_held_run(screen: Screen) -> None: - start_a_held_run(screen) - settings.open() - screen.expect(settings.is_shown, bool, description="Keyboard settings") - keys.update( - {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} - ) - settings.listen_for(ShortcutId.REDO) - - screen.press_shortcut(ShortcutId.UNDO) - - screen.expect(reassign.is_shown, bool, description="the question about reassigning") - assert not settings.is_shown() - - def the_run_ends_and_its_question_waits(screen: Screen) -> None: - let_the_run_end(screen, conversion_hold) - - assert reassign.is_shown() - assert not screen.main.converter.end_prompt.is_shown() - - def cancel_brings_the_dialog_back_first(screen: Screen) -> None: - reassign.cancel() - - screen.expect(settings.is_shown, bool, description="Keyboard settings back") - screen.frames(SETTLING_FRAMES) - assert not screen.main.converter.end_prompt.is_shown() - assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] - - def closing_the_dialog_hands_the_screen_over(screen: Screen) -> None: - settings.cancel() - - screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") - the_end_comes_and_closes(screen) - - screen.scenario( - ask_to_reassign_over_a_held_run, - the_run_ends_and_its_question_waits, - cancel_brings_the_dialog_back_first, - closing_the_dialog_hands_the_screen_over, - ).run() - - -class TestTheRowListeningAfterTheReassignQuestion: - """A row that reads as listening once Cancel answers the reassign question takes the next keys pressed.""" - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: a row listening again after the reassign question takes no keys", - ) - def test_the_same_keys_ask_again(self, screen: Screen) -> None: - settings = screen.keyboard_settings - reassign = settings.reassign_prompt - settings.open() - screen.expect(settings.is_shown, bool, description="Keyboard settings") - settings.listen_for(ShortcutId.REDO) - screen.press_shortcut(ShortcutId.UNDO) - screen.expect(reassign.is_shown, bool, description="the question about reassigning") - reassign.cancel() - screen.expect(settings.is_shown, bool, description="Keyboard settings back") - assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) - - screen.press_shortcut(ShortcutId.UNDO) - - screen.expect(reassign.is_shown, bool, description="the question about reassigning again") - - -class TestTwoGesturesWaitingOnAHeldRebuild: - """A load and a close asked for while an edit is on its way each ask their question once it lands, in order.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_their_questions_come_one_after_the_other( - self, - screen: Screen, - regeneration_hold: RegenerationHold, - ) -> None: - reconstructions = screen.reconstructions - prompt = reconstructions.unsaved_prompt - typed: List[str] = [] - - def edit_while_the_rebuild_is_held(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) - - def load_and_close_while_it_is_held(screen: Screen) -> None: - load_from_the_browser(screen, OTHER_RECONSTRUCTION) - reconstructions.close_from_menu() - - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - assert reconstructions.shows_open(OPEN_RECONSTRUCTION) - - def the_load_asks_first(screen: Screen) -> None: - regeneration_hold.release() - - screen.expect(prompt.is_shown, bool, description="the first question") - assert prompt.title() == screen.words(LOAD_TITLE) - assert len(screen.shown_windows()) == 1 - - def the_close_asks_next(screen: Screen) -> None: - prompt.cancel() - - screen.expect(prompt.title, screen.words(CLOSE_TITLE).__eq__, description="the second question") - assert len(screen.shown_windows()) == 1 - - def nothing_asks_after_them(screen: Screen) -> None: - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the questions answered") - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] - - def leave_letting_it_go(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - - screen.scenario( - edit_while_the_rebuild_is_held, - load_and_close_while_it_is_held, - the_load_asks_first, - the_close_asks_next, - nothing_asks_after_them, - leave_letting_it_go, - ).run() - - -class TestAFailedRebuildBeforeAClose: - """A rebuild that fails while a close waits on it reports the failure first, and the close asks after.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_the_failure_first_and_the_question_after( - self, - screen: Screen, - regeneration_hold: RegenerationHold, - ) -> None: - reconstructions = screen.reconstructions - prompt = reconstructions.unsaved_prompt - notice = screen.error_notice - standing: List[str] = [] - - def edit_a_removed_reconstruction_while_the_rebuild_is_held(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - remove_from_the_browser(screen, OPEN_RECONSTRUCTION) - screen.expect( - screen.title, - titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, - description="the reconstruction left unsaved", - ) - standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) - - raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) - - def close_while_it_is_held(screen: Screen) -> None: - reconstructions.close_from_menu() - - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - - def the_failure_comes_first(screen: Screen) -> None: - regeneration_hold.fail(RebuildBrokeError(REBUILD_FAILURE)) - - screen.expect(notice.is_shown, bool, description="the failure reported") - screen.claim_error(RebuildBrokeError.__name__) - screen.frames(SETTLING_FRAMES) - assert not prompt.is_shown() - assert len(screen.shown_windows()) == 1 - - def the_question_comes_once_the_failure_is_read(screen: Screen) -> None: - notice.dismiss() - - screen.expect(prompt.is_shown, bool, description="the question about closing") - assert prompt.title() == screen.words(CLOSE_TITLE) - - def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: - prompt.cancel() - - screen.expect(prompt.is_shown, operator.not_, description="the question answered") - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] - - def leave_letting_it_go(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about leaving") - - prompt.confirm() - - assert screen.wait_for_exit() - - screen.scenario( - edit_a_removed_reconstruction_while_the_rebuild_is_held, - close_while_it_is_held, - the_failure_comes_first, - the_question_comes_once_the_failure_is_read, - cancel_keeps_the_reconstruction_as_it_stood, - leave_letting_it_go, - ).run() - - -class TestASaveIntoAFolderThatTakesNothing: - """A save into a folder that takes no file shows its failure alone, and the reconstruction stays open.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_the_failure_stands_alone_and_a_save_elsewhere_closes_it(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - prompt = reconstructions.unsaved_prompt - notice = screen.error_notice - locked = RECONSTRUCTIONS_DIRECTORY / LOCKED_FOLDER - kept = RECONSTRUCTIONS_DIRECTORY / KEPT - - def remove_its_file(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - locked.mkdir() - locked.chmod(READ_AND_ENTER) - - remove_from_the_browser(screen, OPEN_RECONSTRUCTION) - - screen.expect( - screen.title, - titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, - description="the reconstruction left unsaved", - ) - - def save_into_the_locked_folder(screen: Screen) -> None: - reconstructions.close_from_menu() - screen.expect(prompt.is_shown, bool, description="the question about closing") - screen.answer_next_dialog(DialogKind.SAVE, locked / KEPT) - - prompt.save() - - screen.expect(notice.is_shown, bool, description="the failure reported") - screen.claim_error(PermissionError.__name__) - assert screen.words(SAVE_FAILED) in notice.words() - screen.frames(SETTLING_FRAMES) - assert not prompt.is_shown() - assert len(screen.shown_windows()) == 1 - - def dismissing_it_leaves_the_reconstruction_open(screen: Screen) -> None: - notice.dismiss() - - screen.expect(notice.is_shown, operator.not_, description="the failure dismissed") - screen.frames(SETTLING_FRAMES) - assert screen.shown_windows() == () - assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - assert not (locked / KEPT).exists() - - def a_save_elsewhere_closes_it(screen: Screen) -> None: - reconstructions.close_from_menu() - screen.expect(prompt.is_shown, bool, description="the question again") - screen.answer_next_dialog(DialogKind.SAVE, kept) - - prompt.save() - - screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") - assert kept.exists() - - screen.scenario( - remove_its_file, - save_into_the_locked_folder, - dismissing_it_leaves_the_reconstruction_open, - a_save_elsewhere_closes_it, - ).run() diff --git a/tests/screens/prompts/vanished/__init__.py b/tests/screens/prompts/vanished/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/test_vanished.py b/tests/screens/prompts/vanished/test_reconstruction_removed.py similarity index 90% rename from tests/screens/prompts/test_vanished.py rename to tests/screens/prompts/vanished/test_reconstruction_removed.py index 4d86bc266..f3c3c0345 100644 --- a/tests/screens/prompts/test_vanished.py +++ b/tests/screens/prompts/vanished/test_reconstruction_removed.py @@ -5,18 +5,18 @@ from sampletones_core.constants.enums import ChannelName from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.application import Startup +from tests.suite.screens.application.startup import Startup from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import ( + edited_title, expect_open, - marked, remove_from_the_browser, stored_levels, titled, ) from tests.suite.screens.vocabulary.dialogs import CANCEL, CLOSE, CLOSE_MESSAGE, CLOSE_TITLE, SAVE -from tests.suite.screens.world import OPEN_RECONSTRUCTION +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION KEPT: Final[str] = "Kept.stn" PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) @@ -25,11 +25,8 @@ RATE_SET_BY_THE_PROJECT: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_locked" -def edited_title(screen: Screen) -> str: - return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) - - def take_its_file_away(screen: Screen) -> None: + """Removes the open reconstruction's file in the browser and waits for it to show as unsaved.""" expect_open(screen, OPEN_RECONSTRUCTION) remove_from_the_browser(screen, OPEN_RECONSTRUCTION) @@ -46,9 +43,14 @@ class TestAReconstructionWhoseFileWasRemoved: @pytest.fixture def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) def test_close_asks_where_a_dismissed_dialog_asks_once_and_cancel_keeps_it(self, screen: Screen) -> None: + """The last Save names a file, which receives the reconstruction's levels. + + The removed file stays gone. + """ reconstructions = screen.reconstructions prompt = reconstructions.unsaved_prompt kept = RECONSTRUCTIONS_DIRECTORY / KEPT @@ -119,6 +121,7 @@ def save_where_asked_writes_it_and_closes(screen: Screen) -> None: reason="bugs-and-todos ยง Bugs: a reconstruction whose file was removed reads as a sample of the project", ) def test_the_locked_rate_says_nothing_of_a_project(self, screen: Screen) -> None: + """The rate stays locked and its explanation words speak of the reconstruction, not of a project.""" reconstructions = screen.reconstructions take_its_file_away(screen) diff --git a/tests/screens/reconstructions/conftest.py b/tests/screens/reconstructions/conftest.py index c5dcbbd02..afb21489d 100644 --- a/tests/screens/reconstructions/conftest.py +++ b/tests/screens/reconstructions/conftest.py @@ -1,6 +1,7 @@ import pytest -from tests.suite.screens.world import World, playing_world +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import playing_world @pytest.fixture diff --git a/tests/screens/reconstructions/edits/__init__.py b/tests/screens/reconstructions/edits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/test_edits.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py similarity index 82% rename from tests/screens/reconstructions/test_edits.py rename to tests/screens/reconstructions/edits/test_gestures_at_once.py index fd005a9ab..5753167e6 100644 --- a/tests/screens/reconstructions/test_edits.py +++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py @@ -7,9 +7,9 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_shared.paths.user import PROJECTS_DIRECTORY -from tests.suite.screens.application import Startup +from tests.suite.screens.application.startup import Startup from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.holds import RegenerationHold +from tests.suite.screens.holds.regeneration import RegenerationHold from tests.suite.screens.screen import Screen from tests.suite.screens.steps.project import save_project_as from tests.suite.screens.steps.reconstructions import ( @@ -23,7 +23,7 @@ from tests.suite.screens.steps.sequencer import open_voice from tests.suite.screens.views.bar_graph import BarGraph from tests.suite.screens.views.instruments import Instruments -from tests.suite.screens.world import SHORT_RECONSTRUCTION, SONG, SONG_SAMPLE, STEMS_RECONSTRUCTION +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION, SONG, SONG_SAMPLE, STEMS_RECONSTRUCTION DRAGGED_ITEM: Final[int] = 5 FIRST_ITEM: Final[int] = 1 @@ -41,6 +41,7 @@ def items(instruments: Instruments, channel: ChannelName, feature: FeatureKey) -> List[int]: + """The items of the envelope that ``channel`` draws for ``feature``, read from the instruments panel.""" return [int(item) for item in instruments.envelope(channel, feature).split()] @@ -51,10 +52,11 @@ def dragged( end: float, bounds: Tuple[int, int], ) -> Tuple[int, List[int]]: - """Drags the bar of the item :data:`DRAGGED_ITEM` to ``end``, and returns the item it moved and the items it leaves. + """Drags the bar of the item :data:`DRAGGED_ITEM` to ``end`` and returns the item it moved and the items + expected after. - The item the plot puts under the release takes the value there, brought within ``bounds``, and - every other item stands as it did. + The item the plot puts under the release takes the value there, brought within ``bounds``, and every other + item stands as it did. """ release: Point = graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=end) index, value = graph.item_under(release) @@ -67,6 +69,7 @@ def dragged( def expect_items( screen: Screen, instruments: Instruments, channel: ChannelName, feature: FeatureKey, expected: List[int] ) -> None: + """Waits until the envelope of ``channel`` and ``feature`` reads ``expected``.""" screen.expect( lambda: items(instruments, channel, feature), expected.__eq__, @@ -75,6 +78,7 @@ def expect_items( def leave_letting_it_go(screen: Screen) -> None: + """Presses Exit, confirms the question about unsaved changes and waits for the application to close.""" prompt = screen.reconstructions.unsaved_prompt screen.press_shortcut(ShortcutId.EXIT) screen.expect(prompt.is_shown, bool, description="the question about leaving") @@ -85,13 +89,19 @@ def leave_letting_it_go(screen: Screen) -> None: class TestADragAndARemovalAtOnce: - """A volume dragged and a recording removed while the drag is on its way both land.""" + """A volume dragged and a recording removed while the drag is on its way both land. + + The rebuild is held while a volume item is dragged; a recording is removed and the removal confirmed + meanwhile. After the rebuild is released, the recording is gone and the dragged value stands. + """ @pytest.fixture def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) def test_both_land(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The removed recording leaves the card and the dragged volume item keeps its new value.""" reconstructions = screen.reconstructions instruments = reconstructions.instruments stems = reconstructions.stems @@ -137,15 +147,20 @@ def both_land(screen: Screen) -> None: class TestADragAndAnUndoAtOnce: """Undo pressed while a drag of a sample is on its way undoes the drag, and the edit saved before it stays. - Consecutive edits of one sample run together into one entry of the history, and a save closes - the run, so the edit before the drag stands in an entry of its own. + Consecutive edits of one sample run together into one entry of the history, and a save closes the run, so + the edit before the drag stands in an entry of its own. + + A sample opens from the song, its first level is raised and the project is saved. With the rebuild held, + an item is dragged and Undo pressed. Once released, the envelope reads as saved and holds there. """ @pytest.fixture def startup(self) -> Startup: + """A project is open at start, with no reconstruction.""" return Startup(reconstruction=None, project=SONG) def test_the_drag_is_undone(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The envelope returns to the values of the saved edit and stays there.""" instruments = screen.reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) edited: List[List[int]] = [] @@ -190,13 +205,19 @@ def the_drag_alone_is_undone(screen: Screen) -> None: class TestADragAndASaveAtOnce: - """A save asked for while a drag is on its way writes the drag.""" + """A save asked for while a drag is on its way writes the drag. + + With the rebuild held, an item is dragged and Save pressed; the file stays as it was until the rebuild is + released, then holds the dragged levels and the title shows a saved file. + """ @pytest.fixture def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) def test_the_file_has_the_drag(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The saved file holds the dragged levels.""" reconstructions = screen.reconstructions instruments = reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) @@ -233,13 +254,19 @@ def the_save_writes_the_drag(screen: Screen) -> None: class TestTwoDimensionsBeforeTheFadeEnds: - """A volume and an arpeggio dragged one after the other while the first is on its way both stay.""" + """A volume and an arpeggio dragged one after the other while the first is on its way both stay. + + With the rebuild held, a volume item is dragged and then an arpeggio item. Once released, both envelopes + hold their dragged values and the title shows unsaved changes. + """ @pytest.fixture def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) def test_both_edits_stay(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The volume and the arpeggio envelopes both read their dragged values.""" instruments = screen.reconstructions.instruments volume = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) arpeggio = instruments.graph(ChannelName.PULSE1, FeatureKey.ARPEGGIO) diff --git a/tests/screens/reconstructions/instruments/__init__.py b/tests/screens/reconstructions/instruments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/instruments/constants.py b/tests/screens/reconstructions/instruments/constants.py new file mode 100644 index 000000000..f20c13e30 --- /dev/null +++ b/tests/screens/reconstructions/instruments/constants.py @@ -0,0 +1,9 @@ +from typing import Final + +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A + +STILL_FRAMES: Final[int] = 20 +NOTE_FRAMES: Final[int] = 30 +FADING: Final[str] = "12 8 4" +NOTHING: Final[str] = "" +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") diff --git a/tests/screens/reconstructions/instruments/steps.py b/tests/screens/reconstructions/instruments/steps.py new file mode 100644 index 000000000..39c40a81b --- /dev/null +++ b/tests/screens/reconstructions/instruments/steps.py @@ -0,0 +1,33 @@ +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import FADING +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import edit_envelope, marked, titled +from tests.suite.screens.steps.sequencer import open_voice +from tests.suite.screens.worlds.recordings import SONG, SONG_INSTRUMENT + + +def open_the_instrument(screen: Screen) -> None: + """Opens the song's instrument from the Sequencer and waits for the Audition switch to appear.""" + instruments = screen.reconstructions.instruments + open_voice(screen, SONG_INSTRUMENT) + + screen.expect(instruments.offers_audition, bool, description="the instrument open") + + +def song_title(screen: Screen, *, unsaved: bool) -> str: + """The window title of the song, marked as unsaved or saved.""" + return titled(screen, marked(SONG.stem, unsaved=unsaved)) + + +def give_it_a_volume(screen: Screen) -> None: + """Opens the song's instrument and types the fading sequence into its Pulse 1 Volume field, leaving the + project unsaved. + """ + open_the_instrument(screen) + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) diff --git a/tests/screens/reconstructions/instruments/test_audition.py b/tests/screens/reconstructions/instruments/test_audition.py new file mode 100644 index 000000000..b183a246d --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_audition.py @@ -0,0 +1,99 @@ +from functools import partial +from typing import Callable, Dict, List, Optional + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import GeneratorName +from tests.screens.reconstructions.instruments.constants import NOTE_FRAMES, PIANO_C +from tests.screens.reconstructions.instruments.steps import give_it_a_volume +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.waveform import read_cursor +from tests.suite.screens.vocabulary.playback import PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT + + +class TestTheAuditionSwitch: + """The Audition switch draws the instrument on the generator it names, and back. + + The instrument gets a volume and shows on the pulse. Choosing the triangle redraws the waveform, + choosing the pulse again restores the first drawing, and the application exits letting the project + go. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_generator_redraws_the_waveform(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + waveform = screen.reconstructions.waveform + drawn: Dict[GeneratorName, List[float]] = {} + + def drawn_now() -> List[float]: + return waveform.drawn(SONG_INSTRUMENT) + + def on_the_pulse(screen: Screen) -> None: + give_it_a_volume(screen) + + assert instruments.audition() == screen.generator_words(GeneratorName.PULSE) + drawn[GeneratorName.PULSE] = drawn_now() + + def on_the_triangle(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.TRIANGLE)) + + drawn[GeneratorName.TRIANGLE] = screen.expect( + drawn_now, drawn[GeneratorName.PULSE].__ne__, description="the waveform redrawn" + ) + + def back_on_the_pulse(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.PULSE)) + + screen.expect(drawn_now, drawn[GeneratorName.PULSE].__eq__, description="the pulse drawn again") + + screen.scenario(on_the_pulse, on_the_triangle, back_on_the_pulse, leave_letting_the_project_go).run() + + +class TestAnInstrumentsWaveform: + """A click on an open instrument's waveform keeps playback silent, while a note key sounds it. + + A reconstruction that plays stands open before the instrument, so any sound from a click is heard as + a moving cursor. The click leaves the cursor still and the Play button as it was; the note key moves + the cursor. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song and a playable reconstruction.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=SONG) + + def test_a_click_plays_nothing_and_a_key_does(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + + def cursors_over(gesture: Callable[[], None]) -> List[Optional[float]]: + with screen.record(partial(read_cursor, waveform.cursor_line)) as recording: + gesture() + screen.frames(NOTE_FRAMES) + + return recording.values() + + def a_click_plays_nothing(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + cursors = cursors_over(partial(waveform.click, 0.5)) + + assert not any(cursors) + assert screen.sequencer.playback.play_entry() == screen.words(PLAY) + + def a_note_key_sounds_it(screen: Screen) -> None: + cursors = cursors_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) + + assert any(cursors) + + screen.scenario(a_click_plays_nothing, a_note_key_sounds_it, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_note_keys.py b/tests/screens/reconstructions/instruments/test_note_keys.py new file mode 100644 index 000000000..c5916f8e3 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_note_keys.py @@ -0,0 +1,101 @@ +import operator +from functools import partial +from typing import Final + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import NOTE_FRAMES, NOTHING, PIANO_C +from tests.screens.reconstructions.instruments.steps import give_it_a_volume +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.keys import IMGUI_DIGIT_ZERO +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.waveform import read_cursor +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG + +PIANO_C_SHARP_UP: Final[int] = IMGUI_DIGIT_ZERO + 2 + + +class TestTheNoteKeys: + """With an instrument open the note keys sound it, a field being typed in takes them as characters, and + the keys a reconstruction gives a meaning keep it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_keys_sound_the_instrument_and_a_field_keeps_them(self, screen: Screen) -> None: + """Piano keys move the waveform cursor; with the Arpeggio field focused, the same key writes its + letter and sounds nothing. + """ + instruments = screen.reconstructions.instruments + + def sounded(key: int) -> bool: + with screen.record(partial(read_cursor, screen.reconstructions.waveform.cursor_line)) as recording: + screen.hand.press_key(key, modifiers=[]) + screen.frames(NOTE_FRAMES) + + return any(reading is not None and reading > 0 for reading in recording.values()) + + def the_keys_sound_it(screen: Screen) -> None: + give_it_a_volume(screen) + + assert sounded(PIANO_C) + assert sounded(PIANO_C_SHARP_UP) + + def a_field_takes_them_as_characters(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + screen.hand.scroll_into_view(field) + screen.hand.click(field) + + assert not sounded(PIANO_C) + assert "z" in instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + screen.scenario(the_keys_sound_it, a_field_takes_them_as_characters, leave_letting_the_project_go).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: an instrument's note keys take Ctrl+Z on the Reconstructions tab", + ) + def test_undo_undoes_with_an_instrument_open(self, screen: Screen) -> None: + """Undo removes the typed volume while the Reconstructions tab shows an instrument.""" + instruments = screen.reconstructions.instruments + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + NOTHING.__eq__, + description="the volume undone", + ) + + +class TestAChannelKeyOnAReconstruction: + """With a reconstruction open, a channel's number key ticks its box above the waveform.""" + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_two_lets_pulse_two_go_and_brings_it_back(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + expect_open(screen, OPEN_RECONSTRUCTION) + assert reconstructions.channel_ticked(ChannelName.PULSE2) + + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + + screen.expect( + lambda: reconstructions.channel_ticked(ChannelName.PULSE2), operator.not_, description="Pulse 2 let go" + ) + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + screen.expect(lambda: reconstructions.channel_ticked(ChannelName.PULSE2), bool, description="Pulse 2 back") diff --git a/tests/screens/reconstructions/instruments/test_pitch_rows.py b/tests/screens/reconstructions/instruments/test_pitch_rows.py new file mode 100644 index 000000000..29c4033e1 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_pitch_rows.py @@ -0,0 +1,190 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import FADING, NOTHING, STILL_FRAMES +from tests.screens.reconstructions.instruments.steps import give_it_a_volume, open_the_instrument, song_title +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import leave_letting_the_project_go +from tests.suite.screens.steps.reconstructions import edit_envelope, expect_open +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG + +BENT_ITEM: Final[int] = 2 +FLAT_PITCH: Final[str] = "0 0 0 0 0" +BEND: Final[float] = 40.0 +PITCH_FLOOR: Final[int] = -128 +PITCH_CEILING: Final[int] = 127 + +PULSE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, + FeatureKey.DUTY_CYCLE, +) + +TRIANGLE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, +) + +NOISE_ROWS: Final[Tuple[FeatureKey, ...]] = (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) +PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.pitch_bend" +HI_PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.hi_pitch_bend" + + +class TestTheRowsEachChannelDraws: + """Pulse and triangle tabs draw Pitch and Hi-pitch rows and the noise tab omits them; a bend field + explains a step. + + Each channel tab shows its rows in order and a pitch stepper, and the Audition switch stays hidden. + Hovering the Pitch and Hi-pitch fields shows their bend explanations; hovering Volume shows none. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_rows_per_channel_and_the_bend_explanation(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + expected = { + ChannelName.PULSE1: PULSE_ROWS, + ChannelName.PULSE2: PULSE_ROWS, + ChannelName.TRIANGLE: TRIANGLE_ROWS, + ChannelName.NOISE: NOISE_ROWS, + } + + def each_tab_draws_its_rows(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + for channel, rows in expected.items(): + instruments.bring_forward(channel) + screen.expect(partial(instruments.rows, channel), rows.__eq__, description=f"the rows of {channel}") + assert instruments.offers_pitch_stepper(channel) + + assert not instruments.offers_audition() + + def a_bend_field_explains_a_step(screen: Screen) -> None: + instruments.bring_forward(ChannelName.PULSE1) + for feature, key in ((FeatureKey.PITCH, PITCH_BEND), (FeatureKey.HI_PITCH, HI_PITCH_BEND)): + field = instruments.field(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + + screen.expect(partial(screen.shows_text, screen.words(key)), bool, description=f"{key} shown") + + def the_volume_field_explains_no_bend(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + screen.frames(STILL_FRAMES) + + assert not screen.shows_text(screen.words(PITCH_BEND)) + assert not screen.shows_text(screen.words(HI_PITCH_BEND)) + + screen.scenario(each_tab_draws_its_rows, a_bend_field_explains_a_step, the_volume_field_explains_no_bend).run() + + +class TestAHandWrittenVoice: + """A hand-written voice offers the Audition switch and only its own tab, and Export follows its + envelopes. + + The opened instrument shows the Pulse 1 tab alone, with Export unavailable. A typed volume makes + Export available, and clearing the volume makes it unavailable again. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_stepper_and_export_answers_while_it_sounds(self, screen: Screen) -> None: + """The pitch stepper stays hidden, and Export turns on or off as the volume is typed or cleared.""" + instruments = screen.reconstructions.instruments + + def its_tab_alone(screen: Screen) -> None: + open_the_instrument(screen) + + assert not instruments.offers_pitch_stepper(ChannelName.PULSE1) + assert [channel for channel in ChannelName if instruments.tab_shown(channel)] == [ChannelName.PULSE1] + assert not instruments.can_export(ChannelName.PULSE1) + + def a_volume_brings_export(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) + + screen.expect(lambda: instruments.can_export(ChannelName.PULSE1), bool, description="Export answering") + + def clearing_it_greys_export_out(screen: Screen) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, NOTHING) + + screen.expect( + lambda: instruments.can_export(ChannelName.PULSE1), operator.not_, description="Export greyed out" + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + screen.scenario( + its_tab_alone, + a_volume_brings_export, + clearing_it_greys_export_out, + leave_letting_the_project_go, + ).run() + + +class TestDraggingAPitchBar: + """Dragging a bar of the Pitch row sets that item to the value of the release and keeps the other items + as they were. + + A flat pitch is typed, then one bar is dragged. The field shows the released value, clamped to the + pitch range, for that item alone. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_item_takes_the_value_under_the_release(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.PITCH) + standing: List[int] = [] + + def give_it_a_flat_pitch(screen: Screen) -> None: + give_it_a_volume(screen) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.PITCH, FLAT_PITCH) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH), + FLAT_PITCH.__eq__, + description="the flat pitch typed", + ) + + screen.hand.scroll_into_view(graph.plot) + + standing.extend(int(item) for item in FLAT_PITCH.split()) + + def drag_one_bar(screen: Screen) -> None: + release = graph.drag_item(BENT_ITEM, start=standing[BENT_ITEM], end=BEND) + + _, under = graph.value_under(release) + expected = [*standing] + expected[BENT_ITEM] = min(max(round(under), PITCH_FLOOR), PITCH_CEILING) + screen.expect( + lambda: [int(item) for item in instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH).split()], + expected.__eq__, + description="one item bent", + ) + assert expected[BENT_ITEM] != standing[BENT_ITEM] + + screen.scenario(give_it_a_flat_pitch, drag_one_bar, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_typed_sequences.py b/tests/screens/reconstructions/instruments/test_typed_sequences.py new file mode 100644 index 000000000..73b6e8442 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_typed_sequences.py @@ -0,0 +1,196 @@ +from functools import partial +from pathlib import Path +from typing import Final, List, Optional + +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.project import ProjectContainer +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.reconstructions.instruments.constants import FADING, NOTHING, STILL_FRAMES +from tests.screens.reconstructions.instruments.steps import open_the_instrument, song_title +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.project import save_project_as +from tests.suite.screens.steps.reconstructions import edit_envelope +from tests.suite.screens.vocabulary.instruments import TOO_LONG +from tests.suite.screens.worlds.recordings import SONG, SONG_INSTRUMENT + +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" +ASIDE: Final[Point] = Point(x=0, y=0) +LOOPING_VOLUME: Final[str] = "15 14 | 12 10" +LOOPING_DUTY: Final[str] = "0 1 | 2" +LOOP_FROM_THE_START: Final[str] = "| 15" +TWO_LOOPS: Final[str] = "15 | | 14" +NOT_A_NUMBER: Final[str] = "x" +TOO_LOUD: Final[str] = "99" +LOUDEST: Final[str] = "15" +TYPING_HINT: Final[str] = "reconstructions.instruments.message.status_sequence" +KEPT_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +INVALID_INPUT: Final[str] = "Invalid PULSE1 data input for VOLUME" + + +def status_over(screen: Screen, item: str) -> str: + """Moves the pointer onto ``item`` from the window's corner, which sets the status line, and reads it.""" + screen.hand.move_to(ASIDE) + screen.hand.scroll_into_view(item) + screen.hand.hover(item) + return screen.status() + + +def saved_instrument(path: Path, name: str) -> Instrument: + """The instrument named ``name`` in the project stored at ``path``.""" + return next( + voice for voice in ProjectContainer.load(path).voices if isinstance(voice, Instrument) and voice.name == name + ) + + +class TestALoopTyped: + """A sequence typed with "|" repeats from the item after it, and a sibling of another length from its + own point. + + The status line explains the "|" before anything is typed. Volume and Duty cycle get one loop each; + after Save as, the stored instrument holds each loop point. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_envelope_keeps_its_own_loop(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def the_status_line_explains_the_bar(screen: Screen) -> None: + open_the_instrument(screen) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + + hint = screen.words(TYPING_HINT).format(instrument_feature=FeatureKey.VOLUME.capitalized) + screen.expect(partial(status_over, screen, field), hint.__eq__, description="the hint about the bar") + + def type_two_loops(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=LOOPING_VOLUME, + title=song_title(screen, unsaved=True), + ) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE, LOOPING_DUTY) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE), + LOOPING_DUTY.__eq__, + description="the duty cycle typed", + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOPING_VOLUME + + def the_project_keeps_each_loop(screen: Screen) -> None: + save_project_as(screen, SAVED_SONG) + + envelopes = saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes + assert (envelopes.volume.items, envelopes.volume.loop_point) == ((15, 14, 12, 10), 2) + assert (envelopes.duty_cycle.items, envelopes.duty_cycle.loop_point) == ((0, 1, 2), 2) + + screen.scenario(the_status_line_explains_the_bar, type_two_loops, the_project_keeps_each_loop).run() + + +class TestSequencesTheFieldAnswers: + """An unreadable sequence is refused and flagged, and a readable one is applied as written. + + A value past the range is brought into it, an empty field clears the dimension, and a sequence + longer than an export keeps is applied with a warning naming what the export keeps. After Save as, + the project holds the last sequence applied. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_refused_clamped_cleared_and_warned(self, screen: Screen) -> None: + """Each kind of typed sequence gets its own answer: an error, a clamp, a clear or a warning.""" + instruments = screen.reconstructions.instruments + long_sequence = " ".join(["8"] * (MAX_SEQUENCE_ITEMS + 1)) + plain: List[Optional[str]] = [] + + def typed(sequence: str) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, sequence) + screen.frames(STILL_FRAMES) + + def theme() -> Optional[str]: + return instruments.field_theme(ChannelName.PULSE1, FeatureKey.VOLUME) + + def unreadable_sequences_are_refused(screen: Screen) -> None: + open_the_instrument(screen) + plain.append(theme()) + + for sequence in (TWO_LOOPS, NOT_A_NUMBER): + typed(sequence) + + screen.claim_error(INVALID_INPUT) + assert theme() == TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == sequence + assert screen.title() == song_title(screen, unsaved=False) + + def a_loop_from_the_start_is_applied(screen: Screen) -> None: + typed(LOOP_FROM_THE_START) + + screen.expect(screen.title, song_title(screen, unsaved=True).__eq__, description="the edit applied") + assert theme() != TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOP_FROM_THE_START + + def a_value_past_the_range_is_brought_into_it(screen: Screen) -> None: + typed(TOO_LOUD) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + LOUDEST.__eq__, + description="the loudest level written", + ) + + def a_sequence_past_every_export_is_applied_with_a_warning(screen: Screen) -> None: + typed(long_sequence) + + screen.expect(theme, TAG_GLOBAL_THEME_INPUT_WARNING.__eq__, description="the warning") + instruments.bring_forward(ChannelName.PULSE1) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + warning = screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=MAX_SEQUENCE_ITEMS + 1, + kept=screen.words(KEPT_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + screen.expect( + partial(status_over, screen, field), warning.__eq__, description="the status naming what each keeps" + ) + + def an_empty_field_clears_it(screen: Screen) -> None: + typed(NOTHING) + + screen.expect(theme, plain[0].__eq__, description="the warning gone") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + def the_project_keeps_what_was_applied_last(screen: Screen) -> None: + typed(FADING) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="the last sequence applied", + ) + + save_project_as(screen, SAVED_SONG) + + assert saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes.volume.items == (12, 8, 4) + + screen.scenario( + unreadable_sequences_are_refused, + a_loop_from_the_start_is_applied, + a_value_past_the_range_is_brought_into_it, + a_sequence_past_every_export_is_applied_with_a_warning, + an_empty_field_clears_it, + the_project_keeps_what_was_applied_last, + ).run() diff --git a/tests/screens/reconstructions/player/__init__.py b/tests/screens/reconstructions/player/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/player/constants.py b/tests/screens/reconstructions/player/constants.py new file mode 100644 index 000000000..836315f30 --- /dev/null +++ b/tests/screens/reconstructions/player/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +RECONSTRUCTION_LINE: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/tests/screens/reconstructions/player/steps.py b/tests/screens/reconstructions/player/steps.py new file mode 100644 index 000000000..af53d4016 --- /dev/null +++ b/tests/screens/reconstructions/player/steps.py @@ -0,0 +1,21 @@ +from tests.suite.screens.screen import Screen +from tests.suite.screens.views.waveform import Waveform + + +def entry(screen: Screen) -> str: + """What the Playback menu's first entry reads: Play while stopped, Pause while playing, Resume while paused.""" + return screen.sequencer.playback.play_entry() + + +def expect_entry(screen: Screen, key: str) -> None: + """Waits until the Playback menu's first entry reads the words of ``key``.""" + screen.expect(lambda: entry(screen), screen.words(key).__eq__, description=f"the Playback menu reading {key}") + + +def advancing(screen: Screen, waveform: Waveform, after: float) -> float: + """Waits until the cursor stands past sample ``after`` and returns where it stands.""" + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: reading > after, + description=f"the cursor moving past sample {after:.0f}", + ) diff --git a/tests/screens/reconstructions/player/test_browser_and_channels.py b/tests/screens/reconstructions/player/test_browser_and_channels.py new file mode 100644 index 000000000..d812d9244 --- /dev/null +++ b/tests/screens/reconstructions/player/test_browser_and_channels.py @@ -0,0 +1,140 @@ +import operator +from typing import Dict, Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.reconstructions.player.constants import RECONSTRUCTION_LINE +from tests.screens.reconstructions.player.steps import advancing, entry, expect_entry +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SECOND_PLAYABLE + +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +SILENCE: Final[float] = 1e-6 +SILENT_SHARE_TOLERANCE: Final[float] = 0.05 + + +def silent_share(drawn: List[float]) -> float: + """The share of drawn heights that sit at silence.""" + return sum(1 for height in drawn if abs(height) < SILENCE) / len(drawn) + + +class TestPlayingFromTheBrowser: + """A reconstruction opened from the browser plays on Space, its cursor moving along the waveform.""" + + def test_space_plays_and_the_cursor_moves_on(self, screen: Screen) -> None: + """The reconstruction opens stopped, and Space starts it with the cursor moving along the waveform.""" + waveform = screen.reconstructions.waveform + + def open_it(screen: Screen) -> None: + load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) + + expect_open(screen, PLAYABLE_RECONSTRUCTION) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def space_plays_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + + screen.scenario(open_it, space_plays_it).run() + + +class TestAnotherReconstructionAfterClosing: + """A reconstruction opened after another was closed draws its cursor as it plays. + + The first reconstruction plays, stops and closes from the menu; the second opens from the browser, plays, + and its cursor moves on. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_its_cursor_moves_on(self, screen: Screen) -> None: + """The second reconstruction plays with its cursor advancing.""" + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + + def play_and_close_the_first(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + reconstructions.close_from_menu() + + screen.expect(reconstructions.file_line, operator.not_, description="nothing open") + + def open_the_second_and_play(screen: Screen) -> None: + load_from_the_browser(screen, SECOND_PLAYABLE) + expect_open(screen, SECOND_PLAYABLE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(play_and_close_the_first, open_the_second_and_play).run() + + +class TestChoosingChannels: + """The waveform draws the channels ticked above it, and ticking one back draws what it drew before. + + The scenario unticks Pulse 1 and Triangle, which leaves the beat alone, and ticks them again. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_beat_alone_falls_silent_between_its_strokes(self, screen: Screen) -> None: + """The beat alone is silent between its strokes; ticking the channels back restores the drawing.""" + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + line = screen.words(RECONSTRUCTION_LINE) + drawn: Dict[str, List[float]] = {} + + def every_channel_sounds_throughout(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + drawn["all"] = waveform.drawn(line) + + assert silent_share(drawn["all"]) == 0.0 + + def the_beat_alone(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + beat = screen.expect( + lambda: waveform.drawn(line), + drawn["all"].__ne__, + description="the waveform redrawn", + ) + assert abs(silent_share(beat) - (BEAT_FRAMES - BEAT_LENGTH) / BEAT_FRAMES) <= SILENT_SHARE_TOLERANCE + + def every_channel_back(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + screen.expect(lambda: waveform.drawn(line), drawn["all"].__eq__, description="the waveform as before") + + screen.scenario(every_channel_sounds_throughout, the_beat_alone, every_channel_back).run() diff --git a/tests/screens/reconstructions/player/test_source_switch.py b/tests/screens/reconstructions/player/test_source_switch.py new file mode 100644 index 000000000..d96c4a198 --- /dev/null +++ b/tests/screens/reconstructions/player/test_source_switch.py @@ -0,0 +1,113 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from tests.screens.reconstructions.player.constants import RECONSTRUCTION_LINE +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, TAKES, playing_world + +ORIGINAL_SWITCH: Final[str] = "reconstructions.reconstruction.label.original_audio_radio" +RECONSTRUCTION_SWITCH: Final[str] = "reconstructions.reconstruction.label.reconstruction_radio" +ORIGINAL_LINE: Final[str] = "global.graph.label.waveform_original" + + +class TestTheSourceSwitch: + """The switch above the waveform puts the source it names on top of the waveform, and back.""" + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_source_chosen_is_drawn_on_top(self, screen: Screen) -> None: + """The reconstruction starts on top, the original takes the top when chosen, and the reconstruction + again. + """ + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + original = waveform.series_tag(screen.words(ORIGINAL_LINE)) + reconstruction = waveform.series_tag(screen.words(RECONSTRUCTION_LINE)) + + def the_reconstruction_on_top(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + assert waveform.series() == (original, reconstruction) + + def the_original_chosen(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(ORIGINAL_SWITCH)) + + screen.expect(waveform.series, (reconstruction, original).__eq__, description="the original on top") + + def the_reconstruction_chosen_again(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(RECONSTRUCTION_SWITCH)) + + screen.expect(waveform.series, (original, reconstruction).__eq__, description="the reconstruction on top") + + screen.scenario(the_reconstruction_on_top, the_original_chosen, the_reconstruction_chosen_again).run() + + +class TestTheSourceSwitchWithItsRecordingGone: + """A reconstruction whose recording is gone offers the reconstruction alone. + + The home lacks the recording. Opening the reconstruction shows a notice naming the missing file; once + dismissed, the switch offers no other source. + """ + + @pytest.fixture + def world(self) -> World: + """The Reconstructions home with the first recording's file removed.""" + world = playing_world() + gone = Path.cwd() / TAKES[0] + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(file for file in world.files if not (isinstance(file, Recording) and file.destination == gone)), + ) + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_switch_stays_on_the_reconstruction(self, screen: Screen) -> None: + """The notice names the missing recording, and the switch offers the reconstruction only.""" + reconstructions = screen.reconstructions + notice = screen.file_not_found_notice + + def the_missing_recording_is_named(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + + assert str(Path.cwd() / TAKES[0]) in notice.words() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + def the_switch_offers_nothing_else(screen: Screen) -> None: + assert not reconstructions.can_choose_audio_source() + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + + screen.scenario(the_missing_recording_is_named, the_switch_offers_nothing_else).run() + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a reconstruction whose recording is missing draws a flat original line", + ) + def test_the_waveform_draws_the_reconstruction_alone(self, screen: Screen) -> None: + """The waveform shows one line, the reconstruction's.""" + waveform = screen.reconstructions.waveform + notice = screen.file_not_found_notice + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + assert waveform.series() == (waveform.series_tag(screen.words(RECONSTRUCTION_LINE)),) diff --git a/tests/screens/reconstructions/player/test_waveform_gestures.py b/tests/screens/reconstructions/player/test_waveform_gestures.py new file mode 100644 index 000000000..3095d0577 --- /dev/null +++ b/tests/screens/reconstructions/player/test_waveform_gestures.py @@ -0,0 +1,271 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.reconstructions.player.steps import advancing, entry, expect_entry +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.views.waveform import Waveform +from tests.suite.screens.vocabulary.playback import PAUSE, PLAY +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +SAMPLE_RATE: Final[int] = 44100 +PLAYING_WINDOW_SECONDS: Final[float] = 3.0 +STILL_FRAMES: Final[int] = 20 +ZOOM_NOTCHES: Final[int] = 3 +WIDTH_TOLERANCE: Final[float] = 0.01 +SPACE_BURST: Final[int] = 5 +PAST_THE_EDGE: Final[float] = 1.4 +RESUME: Final[str] = "global.menu.label.item_playback_resume" + + +def cursor(waveform: Waveform) -> float: + """The cursor's sample on the waveform, which must be drawn.""" + found = waveform.cursor() + assert found is not None, "no cursor stands on the waveform" + return found + + +def playing_from(screen: Screen, waveform: Waveform, sample: float) -> float: + """Waits for the cursor to stand at ``sample`` or a little past it, where playing from ``sample`` puts it.""" + window = PLAYING_WINDOW_SECONDS * SAMPLE_RATE + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: sample <= reading < sample + window, + description=f"the cursor playing on from sample {sample:.0f}", + ) + + +def held_still(screen: Screen, waveform: Waveform) -> float: + """The cursor's sample, read twice some frames apart and found the same.""" + standing = cursor(waveform) + screen.frames(STILL_FRAMES) + assert waveform.cursor() == standing + return standing + + +def width(limits: Tuple[float, float]) -> float: + """The span a pair of axis limits covers.""" + low, high = limits + return high - low + + +class TestTheWaveformGestures: + """A click plays from a point, moves the cursor while paused and jumps while playing; Space pauses and + resumes. + + A drag pans the view and a double-click fits it, and both leave the cursor and what plays as they were. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_clicks_and_space_steer_playback(self, screen: Screen) -> None: + """A click plays from there, Space pauses and resumes, and Stop takes the cursor away.""" + waveform = screen.reconstructions.waveform + stood: List[float] = [] + + def a_click_while_stopped_plays_from_there(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + + def space_pauses_and_the_cursor_holds(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + held_still(screen, waveform) + + def a_click_while_paused_moves_the_cursor_and_stays_paused(screen: Screen) -> None: + under = waveform.sample_under(0.25) + + waveform.click(0.25) + + screen.expect(waveform.cursor, float(round(under)).__eq__, description="the cursor where clicked") + stood.append(held_still(screen, waveform)) + assert entry(screen) == screen.words(RESUME) + + def space_resumes_from_the_cursor(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, stood[0]) + + def a_click_while_playing_jumps_there(screen: Screen) -> None: + under = waveform.sample_under(0.75) + + waveform.click(0.75) + + playing_from(screen, waveform, under) + assert entry(screen) == screen.words(PAUSE) + + def stop_takes_the_cursor_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + expect_entry(screen, PLAY) + screen.expect(waveform.cursor, operator.not_, description="no cursor") + + screen.scenario( + a_click_while_stopped_plays_from_there, + space_pauses_and_the_cursor_holds, + a_click_while_paused_moves_the_cursor_and_stays_paused, + space_resumes_from_the_cursor, + a_click_while_playing_jumps_there, + stop_takes_the_cursor_away, + ).run() + + def test_a_drag_pans_and_a_double_click_fits_without_touching_playback(self, screen: Screen) -> None: + """A drag pans the zoomed view and a double-click fits it again, while the pause, the playing and the + stop go on as they were. + """ + waveform = screen.reconstructions.waveform + fitted: List[Tuple[float, float]] = [] + stood: List[float] = [] + + def pause_partway(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + fitted.append( + screen.expect(waveform.limits, lambda limits: limits[1] > SAMPLE_RATE, description="the view fitted") + ) + waveform.click(0.5) + expect_entry(screen, PAUSE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + stood.append(held_still(screen, waveform)) + + def zoom_in_and_drag(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + zoomed = screen.expect( + waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed" + ) + + waveform.drag(0.6, 0.4) + + panned = screen.expect(waveform.limits, lambda limits: limits[0] > zoomed[0], description="panned") + assert abs(width(panned) - width(zoomed)) <= WIDTH_TOLERANCE * width(zoomed) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_fits_and_leaves_the_pause(screen: Screen) -> None: + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_leaves_playing_playing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + before = cursor(waveform) + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + assert entry(screen) == screen.words(PAUSE) + advancing(screen, waveform, before) + + def a_double_click_leaves_stopped_stopped(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + screen.scenario( + pause_partway, + zoom_in_and_drag, + a_double_click_fits_and_leaves_the_pause, + a_double_click_leaves_playing_playing, + a_double_click_leaves_stopped_stopped, + ).run() + + +class TestPressesThatAreNoClick: + """A triple click and a drag that leaves the plot play nothing, and the next plain click plays. + + The scenario triple-clicks, then drags past the plot's edge on a zoomed view, and expects the cursor + absent and the menu reading Play both times. A plain click then plays, and Stop ends it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_triple_click_and_a_drag_off_the_plot_play_nothing(self, screen: Screen) -> None: + """The triple click and the drag off the plot leave the cursor absent and the menu reading Play.""" + waveform = screen.reconstructions.waveform + + def a_triple_click(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + waveform.triple_click(0.5) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_drag_off_the_plot(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + waveform.drag(0.5, PAST_THE_EDGE) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_plain_click_plays(screen: Screen) -> None: + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(a_triple_click, a_drag_off_the_plot, a_plain_click_plays).run() + + +class TestABurstOfSpace: + """Five presses of Space from a stop play, pause, resume, pause and resume: the burst ends playing.""" + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_an_odd_burst_ends_playing(self, screen: Screen) -> None: + """After the burst the menu reads Pause and the cursor moves on.""" + waveform = screen.reconstructions.waveform + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + for _ in range(SPACE_BURST): + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + advancing(screen, waveform, cursor(waveform)) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) diff --git a/tests/screens/reconstructions/stems/__init__.py b/tests/screens/reconstructions/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/test_stems.py b/tests/screens/reconstructions/stems/test_stems_card.py similarity index 84% rename from tests/screens/reconstructions/test_stems.py rename to tests/screens/reconstructions/stems/test_stems_card.py index 806ebffe6..80f64bf7e 100644 --- a/tests/screens/reconstructions/test_stems.py +++ b/tests/screens/reconstructions/stems/test_stems_card.py @@ -8,16 +8,12 @@ from sampletones_application.tags.general import TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.application import Startup +from tests.suite.screens.application.startup import Startup from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser, marked, titled from tests.suite.screens.views.stems import StemsCard from tests.suite.screens.vocabulary.playback import PAUSE -from tests.suite.screens.world import ( - SHORT_RECONSTRUCTION, - STEM_TAKES, - STEMS_RECONSTRUCTION, -) +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION, STEM_TAKES, STEMS_RECONSTRUCTION KEYS: Final[Tuple[str, ...]] = ("0", "1", "2") FIRST: Final[str] = "0" @@ -32,25 +28,34 @@ def take(index: int) -> Path: + """The path of the stem recording at ``index``, under the working directory.""" return Path.cwd() / STEM_TAKES[index] def swatches(stems: StemsCard, keys: Tuple[str, ...]) -> Dict[str, Tuple[float, ...]]: + """The color of each row of ``keys`` on the stems card.""" return {key: stems.swatch(key) for key in keys} def stems_title(screen: Screen, *, unsaved: bool) -> str: + """The window title of the stems reconstruction, marked unsaved or saved.""" return titled(screen, marked(STEMS_RECONSTRUCTION.name, unsaved=unsaved)) class TestRevealingARecording: - """A double-click on a row shows its recording in the file manager; a click shows nothing; no row stays lit.""" + """A double-click on a row shows its recording in the file manager; a click shows nothing; no row stays lit. + + The scenario clicks a row, double-clicks the first row and then the last, and expects the recordings shown + in that order and no row highlighted. + """ @pytest.fixture def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) def test_a_double_click_shows_the_recording_and_leaves_the_row_plain(self, screen: Screen) -> None: + """Each double-click shows its own recording, and the rows keep their plain look.""" stems = screen.reconstructions.stems def a_click_shows_nothing(screen: Screen) -> None: @@ -78,13 +83,20 @@ def another_row_shows_its_own(screen: Screen) -> None: class TestRemovingARecording: - """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go.""" + """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go. + + The scenario cancels the question, then removes the middle recording and confirms. The rest play, the + first goes too, and the last recording stays without a remove gesture. Saved and opened again, the card + lists only the last recording in its old color. + """ @pytest.fixture def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) def test_cancel_keeps_it_and_remove_leaves_the_rest_as_they_were(self, screen: Screen) -> None: + """Cancel keeps every row; Remove drops one, and the others keep their colors and play.""" reconstructions = screen.reconstructions stems = reconstructions.stems prompt = stems.remove_prompt @@ -167,15 +179,20 @@ def saved_and_opened_again_it_keeps_its_color(screen: Screen) -> None: class TestSilencingARecording: """A recording whose every frame falls silent leaves the card, and the others keep their colors. - With the middle recording heard alone, each channel's volume is dragged to silence across every - bar, which leaves the channel standing by for that recording. + With the middle recording heard alone, each channel's volume is dragged to silence across every bar, which + leaves the channel standing by for that recording. + + The scenario ends on the middle row gone and the other rows in their old colors, then leaves the + application. """ @pytest.fixture def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) def test_its_row_leaves_and_the_others_keep_their_colors(self, screen: Screen) -> None: + """The silenced recording's row leaves the card and the other rows keep their colors.""" reconstructions = screen.reconstructions stems = reconstructions.stems instruments = reconstructions.instruments @@ -225,15 +242,16 @@ def leave_letting_it_go(screen: Screen) -> None: class TestLettingAChannelGo: """A channel whose volume is dragged to silence on every frame mutes its tab and exports nothing. - The silence is dragged at a height that rounds to it, above the strip under the bars that a - press ignores. + The silence is dragged at a height that rounds to it, above the strip under the bars that a press ignores. """ @pytest.fixture def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) def test_the_tab_mutes_and_its_size_reads_nothing(self, screen: Screen) -> None: + """The channel's tab takes the muted look, its size reads zero bytes and its export is unavailable.""" reconstructions = screen.reconstructions instruments = reconstructions.instruments graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) diff --git a/tests/screens/reconstructions/test_instruments.py b/tests/screens/reconstructions/test_instruments.py deleted file mode 100644 index fefe9c2f0..000000000 --- a/tests/screens/reconstructions/test_instruments.py +++ /dev/null @@ -1,536 +0,0 @@ -import operator -from functools import partial -from pathlib import Path -from typing import Callable, Dict, Final, List, Optional, Tuple - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName -from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS -from sampletones_core.project import ProjectContainer -from sampletones_core.project.voices.instrument import Instrument -from sampletones_shared.paths.user import PROJECTS_DIRECTORY -from tests.suite.screens.application import Startup -from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.keys import IMGUI_DIGIT_ZERO, IMGUI_LETTER_A -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.project import save_project_as -from tests.suite.screens.steps.reconstructions import edit_envelope, expect_open, marked, titled -from tests.suite.screens.steps.sequencer import open_voice -from tests.suite.screens.views.waveform import read_cursor -from tests.suite.screens.vocabulary.playback import PLAY -from tests.suite.screens.world import OPEN_RECONSTRUCTION, PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT - -SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" -ASIDE: Final[Point] = Point(x=0, y=0) -STILL_FRAMES: Final[int] = 20 -NOTE_FRAMES: Final[int] = 30 -FADING: Final[str] = "12 8 4" -LOOPING_VOLUME: Final[str] = "15 14 | 12 10" -LOOPING_DUTY: Final[str] = "0 1 | 2" -LOOP_FROM_THE_START: Final[str] = "| 15" -TWO_LOOPS: Final[str] = "15 | | 14" -NOT_A_NUMBER: Final[str] = "x" -TOO_LOUD: Final[str] = "99" -LOUDEST: Final[str] = "15" -NOTHING: Final[str] = "" -BENT_ITEM: Final[int] = 2 -FLAT_PITCH: Final[str] = "0 0 0 0 0" -BEND: Final[float] = 40.0 -PITCH_FLOOR: Final[int] = -128 -PITCH_CEILING: Final[int] = 127 -PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") -PIANO_C_SHARP_UP: Final[int] = IMGUI_DIGIT_ZERO + 2 -PULSE_ROWS: Final[Tuple[FeatureKey, ...]] = ( - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - FeatureKey.PITCH, - FeatureKey.HI_PITCH, - FeatureKey.DUTY_CYCLE, -) -TRIANGLE_ROWS: Final[Tuple[FeatureKey, ...]] = ( - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - FeatureKey.PITCH, - FeatureKey.HI_PITCH, -) -NOISE_ROWS: Final[Tuple[FeatureKey, ...]] = (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) -PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.pitch_bend" -HI_PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.hi_pitch_bend" -TYPING_HINT: Final[str] = "reconstructions.instruments.message.status_sequence" -TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" -KEPT_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" -INVALID_INPUT: Final[str] = "Invalid PULSE1 data input for VOLUME" - - -def status_over(screen: Screen, item: str) -> str: - """Brings the pointer onto ``item`` from the window's corner, which is what sets the status line, and reads it.""" - screen.hand.move_to(ASIDE) - screen.hand.scroll_into_view(item) - screen.hand.hover(item) - return screen.status() - - -def saved_instrument(path: Path, name: str) -> Instrument: - """The instrument named ``name`` in the project stored at ``path``.""" - return next( - voice for voice in ProjectContainer.load(path).voices if isinstance(voice, Instrument) and voice.name == name - ) - - -def open_the_instrument(screen: Screen) -> None: - instruments = screen.reconstructions.instruments - open_voice(screen, SONG_INSTRUMENT) - - screen.expect(instruments.offers_audition, bool, description="the instrument open") - - -def song_title(screen: Screen, *, unsaved: bool) -> str: - return titled(screen, marked(SONG.stem, unsaved=unsaved)) - - -def leave_letting_the_project_go(screen: Screen) -> None: - """Exits, answering Exit to the question about the project.""" - prompt = screen.project.unsaved_prompt - screen.press_shortcut(ShortcutId.EXIT) - screen.expect(prompt.is_shown, bool, description="the question about the project") - - prompt.confirm() - - assert screen.wait_for_exit() - - -def give_it_a_volume(screen: Screen) -> None: - open_the_instrument(screen) - edit_envelope( - screen, - channel=ChannelName.PULSE1, - feature=FeatureKey.VOLUME, - sequence=FADING, - title=song_title(screen, unsaved=True), - ) - - -class TestTheRowsEachChannelDraws: - """Pulse and triangle tabs draw Pitch and Hi-pitch rows, the noise tab neither; a bend field explains a step.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_rows_per_channel_and_the_bend_explanation(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - expected = { - ChannelName.PULSE1: PULSE_ROWS, - ChannelName.PULSE2: PULSE_ROWS, - ChannelName.TRIANGLE: TRIANGLE_ROWS, - ChannelName.NOISE: NOISE_ROWS, - } - - def each_tab_draws_its_rows(screen: Screen) -> None: - expect_open(screen, OPEN_RECONSTRUCTION) - - for channel, rows in expected.items(): - instruments.bring_forward(channel) - screen.expect(partial(instruments.rows, channel), rows.__eq__, description=f"the rows of {channel}") - assert instruments.offers_pitch_stepper(channel) - - assert not instruments.offers_audition() - - def a_bend_field_explains_a_step(screen: Screen) -> None: - instruments.bring_forward(ChannelName.PULSE1) - for feature, key in ((FeatureKey.PITCH, PITCH_BEND), (FeatureKey.HI_PITCH, HI_PITCH_BEND)): - field = instruments.field(ChannelName.PULSE1, feature) - screen.hand.scroll_into_view(field) - screen.hand.hover(field) - - screen.expect(partial(screen.shows_text, screen.words(key)), bool, description=f"{key} shown") - - def the_volume_field_explains_no_bend(screen: Screen) -> None: - field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) - screen.hand.scroll_into_view(field) - screen.hand.hover(field) - screen.frames(STILL_FRAMES) - - assert not screen.shows_text(screen.words(PITCH_BEND)) - assert not screen.shows_text(screen.words(HI_PITCH_BEND)) - - screen.scenario(each_tab_draws_its_rows, a_bend_field_explains_a_step, the_volume_field_explains_no_bend).run() - - -class TestAHandWrittenVoice: - """A hand-written voice offers the Audition switch and no pitch stepper, and Export follows its envelopes.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_no_stepper_and_export_answers_while_it_sounds(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - - def its_tab_alone(screen: Screen) -> None: - open_the_instrument(screen) - - assert not instruments.offers_pitch_stepper(ChannelName.PULSE1) - assert [channel for channel in ChannelName if instruments.tab_shown(channel)] == [ChannelName.PULSE1] - assert not instruments.can_export(ChannelName.PULSE1) - - def a_volume_brings_export(screen: Screen) -> None: - edit_envelope( - screen, - channel=ChannelName.PULSE1, - feature=FeatureKey.VOLUME, - sequence=FADING, - title=song_title(screen, unsaved=True), - ) - - screen.expect(lambda: instruments.can_export(ChannelName.PULSE1), bool, description="Export answering") - - def clearing_it_greys_export_out(screen: Screen) -> None: - instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, NOTHING) - - screen.expect( - lambda: instruments.can_export(ChannelName.PULSE1), operator.not_, description="Export greyed out" - ) - assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING - - screen.scenario( - its_tab_alone, - a_volume_brings_export, - clearing_it_greys_export_out, - leave_letting_the_project_go, - ).run() - - -class TestALoopTyped: - """A sequence typed with "|" repeats from the item after it, and a sibling of another length from its own point. - - The status line explains the "|" before anything is typed. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_each_envelope_keeps_its_own_loop(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - - def the_status_line_explains_the_bar(screen: Screen) -> None: - open_the_instrument(screen) - field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) - - hint = screen.words(TYPING_HINT).format(instrument_feature=FeatureKey.VOLUME.capitalized) - screen.expect(partial(status_over, screen, field), hint.__eq__, description="the hint about the bar") - - def type_two_loops(screen: Screen) -> None: - edit_envelope( - screen, - channel=ChannelName.PULSE1, - feature=FeatureKey.VOLUME, - sequence=LOOPING_VOLUME, - title=song_title(screen, unsaved=True), - ) - instruments.type_envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE, LOOPING_DUTY) - - screen.expect( - lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE), - LOOPING_DUTY.__eq__, - description="the duty cycle typed", - ) - assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOPING_VOLUME - - def the_project_keeps_each_loop(screen: Screen) -> None: - save_project_as(screen, SAVED_SONG) - - envelopes = saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes - assert (envelopes.volume.items, envelopes.volume.loop_point) == ((15, 14, 12, 10), 2) - assert (envelopes.duty_cycle.items, envelopes.duty_cycle.loop_point) == ((0, 1, 2), 2) - - screen.scenario(the_status_line_explains_the_bar, type_two_loops, the_project_keeps_each_loop).run() - - -class TestSequencesTheFieldAnswers: - """A sequence the field cannot read is refused and leaves the envelope; one it can read is applied as written. - - A value past the range is brought into it, an empty field clears the dimension, and one longer than - an export keeps is applied with a warning naming what the export keeps. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_refused_clamped_cleared_and_warned(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - long_sequence = " ".join(["8"] * (MAX_SEQUENCE_ITEMS + 1)) - plain: List[Optional[str]] = [] - - def typed(sequence: str) -> None: - instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, sequence) - screen.frames(STILL_FRAMES) - - def theme() -> Optional[str]: - return instruments.field_theme(ChannelName.PULSE1, FeatureKey.VOLUME) - - def unreadable_sequences_are_refused(screen: Screen) -> None: - open_the_instrument(screen) - plain.append(theme()) - - for sequence in (TWO_LOOPS, NOT_A_NUMBER): - typed(sequence) - - screen.claim_error(INVALID_INPUT) - assert theme() == TAG_GLOBAL_THEME_INPUT_INVALID - assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == sequence - assert screen.title() == song_title(screen, unsaved=False) - - def a_loop_from_the_start_is_applied(screen: Screen) -> None: - typed(LOOP_FROM_THE_START) - - screen.expect(screen.title, song_title(screen, unsaved=True).__eq__, description="the edit applied") - assert theme() != TAG_GLOBAL_THEME_INPUT_INVALID - assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOP_FROM_THE_START - - def a_value_past_the_range_is_brought_into_it(screen: Screen) -> None: - typed(TOO_LOUD) - - screen.expect( - lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), - LOUDEST.__eq__, - description="the loudest level written", - ) - - def a_sequence_past_every_export_is_applied_with_a_warning(screen: Screen) -> None: - typed(long_sequence) - - screen.expect(theme, TAG_GLOBAL_THEME_INPUT_WARNING.__eq__, description="the warning") - instruments.bring_forward(ChannelName.PULSE1) - field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) - warning = screen.words(TOO_LONG).format( - instrument_feature=FeatureKey.VOLUME.capitalized, - items=MAX_SEQUENCE_ITEMS + 1, - kept=screen.words(KEPT_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), - ) - screen.expect( - partial(status_over, screen, field), warning.__eq__, description="the status naming what each keeps" - ) - - def an_empty_field_clears_it(screen: Screen) -> None: - typed(NOTHING) - - screen.expect(theme, plain[0].__eq__, description="the warning gone") - assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING - - def the_project_keeps_what_was_applied_last(screen: Screen) -> None: - typed(FADING) - screen.expect( - lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), - FADING.__eq__, - description="the last sequence applied", - ) - - save_project_as(screen, SAVED_SONG) - - assert saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes.volume.items == (12, 8, 4) - - screen.scenario( - unreadable_sequences_are_refused, - a_loop_from_the_start_is_applied, - a_value_past_the_range_is_brought_into_it, - a_sequence_past_every_export_is_applied_with_a_warning, - an_empty_field_clears_it, - the_project_keeps_what_was_applied_last, - ).run() - - -class TestDraggingAPitchBar: - """Dragging a bar of the Pitch row sets that item to the value the drag lets go at, and only that item.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_the_item_takes_the_value_under_the_release(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - graph = instruments.graph(ChannelName.PULSE1, FeatureKey.PITCH) - standing: List[int] = [] - - def give_it_a_flat_pitch(screen: Screen) -> None: - give_it_a_volume(screen) - instruments.type_envelope(ChannelName.PULSE1, FeatureKey.PITCH, FLAT_PITCH) - screen.expect( - lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH), - FLAT_PITCH.__eq__, - description="the flat pitch typed", - ) - - screen.hand.scroll_into_view(graph.plot) - - standing.extend(int(item) for item in FLAT_PITCH.split()) - - def drag_one_bar(screen: Screen) -> None: - release = graph.drag_item(BENT_ITEM, start=standing[BENT_ITEM], end=BEND) - - _, under = graph.value_under(release) - expected = [*standing] - expected[BENT_ITEM] = min(max(round(under), PITCH_FLOOR), PITCH_CEILING) - screen.expect( - lambda: [int(item) for item in instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH).split()], - expected.__eq__, - description="one item bent", - ) - assert expected[BENT_ITEM] != standing[BENT_ITEM] - - screen.scenario(give_it_a_flat_pitch, drag_one_bar, leave_letting_the_project_go).run() - - -class TestTheNoteKeys: - """With an instrument open the note keys sound it, a field being typed in takes them as characters, - and the keys a reconstruction gives a meaning keep it.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_keys_sound_the_instrument_and_a_field_keeps_them(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - - def sounded(key: int) -> bool: - with screen.record(partial(read_cursor, screen.reconstructions.waveform.cursor_line)) as recording: - screen.hand.press_key(key, modifiers=[]) - screen.frames(NOTE_FRAMES) - - return any(reading is not None and reading > 0 for reading in recording.values()) - - def the_keys_sound_it(screen: Screen) -> None: - give_it_a_volume(screen) - - assert sounded(PIANO_C) - assert sounded(PIANO_C_SHARP_UP) - - def a_field_takes_them_as_characters(screen: Screen) -> None: - field = instruments.field(ChannelName.PULSE1, FeatureKey.ARPEGGIO) - screen.hand.scroll_into_view(field) - screen.hand.click(field) - - assert not sounded(PIANO_C) - assert "z" in instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) - - screen.scenario(the_keys_sound_it, a_field_takes_them_as_characters, leave_letting_the_project_go).run() - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: an instrument's note keys take Ctrl+Z on the Reconstructions tab", - ) - def test_undo_undoes_with_an_instrument_open(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - give_it_a_volume(screen) - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - - screen.press_shortcut(ShortcutId.UNDO) - - screen.expect( - lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), - NOTHING.__eq__, - description="the volume undone", - ) - - -class TestAChannelKeyOnAReconstruction: - """With a reconstruction open, a channel's number key ticks its box above the waveform.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) - - def test_two_lets_pulse_two_go_and_brings_it_back(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - expect_open(screen, OPEN_RECONSTRUCTION) - assert reconstructions.channel_ticked(ChannelName.PULSE2) - - screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) - - screen.expect( - lambda: reconstructions.channel_ticked(ChannelName.PULSE2), operator.not_, description="Pulse 2 let go" - ) - screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) - screen.expect(lambda: reconstructions.channel_ticked(ChannelName.PULSE2), bool, description="Pulse 2 back") - - -class TestTheAuditionSwitch: - """The Audition switch draws the instrument on the generator it names, and back.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=None, project=SONG) - - def test_each_generator_redraws_the_waveform(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - waveform = screen.reconstructions.waveform - drawn: Dict[GeneratorName, List[float]] = {} - - def drawn_now() -> List[float]: - return waveform.drawn(SONG_INSTRUMENT) - - def on_the_pulse(screen: Screen) -> None: - give_it_a_volume(screen) - - assert instruments.audition() == screen.generator_words(GeneratorName.PULSE) - drawn[GeneratorName.PULSE] = drawn_now() - - def on_the_triangle(screen: Screen) -> None: - instruments.choose_audition(screen.generator_words(GeneratorName.TRIANGLE)) - - drawn[GeneratorName.TRIANGLE] = screen.expect( - drawn_now, drawn[GeneratorName.PULSE].__ne__, description="the waveform redrawn" - ) - - def back_on_the_pulse(screen: Screen) -> None: - instruments.choose_audition(screen.generator_words(GeneratorName.PULSE)) - - screen.expect(drawn_now, drawn[GeneratorName.PULSE].__eq__, description="the pulse drawn again") - - screen.scenario(on_the_pulse, on_the_triangle, back_on_the_pulse, leave_letting_the_project_go).run() - - -class TestAnInstrumentsWaveform: - """A click on an open instrument's waveform plays nothing, while a note key sounds it. - - A reconstruction that plays stands open before the instrument, so a click that sounds anything - at all is heard. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=SONG) - - def test_a_click_plays_nothing_and_a_key_does(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - - def cursors_over(gesture: Callable[[], None]) -> List[Optional[float]]: - with screen.record(partial(read_cursor, waveform.cursor_line)) as recording: - gesture() - screen.frames(NOTE_FRAMES) - - return recording.values() - - def a_click_plays_nothing(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - give_it_a_volume(screen) - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - - cursors = cursors_over(partial(waveform.click, 0.5)) - - assert not any(cursors) - assert screen.sequencer.playback.play_entry() == screen.words(PLAY) - - def a_note_key_sounds_it(screen: Screen) -> None: - cursors = cursors_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) - - assert any(cursors) - - screen.scenario(a_click_plays_nothing, a_note_key_sounds_it, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/test_player.py b/tests/screens/reconstructions/test_player.py deleted file mode 100644 index 0a5a7b72e..000000000 --- a/tests/screens/reconstructions/test_player.py +++ /dev/null @@ -1,480 +0,0 @@ -import operator -from pathlib import Path -from typing import Dict, Final, List, Tuple - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.application import Startup -from tests.suite.screens.screen import Screen -from tests.suite.screens.seeds import Recording -from tests.suite.screens.steps.reconstructions import expect_open, load_from_the_browser -from tests.suite.screens.views.waveform import Waveform -from tests.suite.screens.vocabulary.playback import PAUSE, PLAY -from tests.suite.screens.world import ( - PLAYABLE_RECONSTRUCTION, - SECOND_PLAYABLE, - TAKES, - World, - playing_world, -) - -SAMPLE_RATE: Final[int] = 44100 -PLAYING_WINDOW_SECONDS: Final[float] = 3.0 -STILL_FRAMES: Final[int] = 20 -ZOOM_NOTCHES: Final[int] = 3 -WIDTH_TOLERANCE: Final[float] = 0.01 -SPACE_BURST: Final[int] = 5 -PAST_THE_EDGE: Final[float] = 1.4 -BEAT_FRAMES: Final[int] = 15 -BEAT_LENGTH: Final[int] = 3 -SILENCE: Final[float] = 1e-6 -SILENT_SHARE_TOLERANCE: Final[float] = 0.05 -RESUME: Final[str] = "global.menu.label.item_playback_resume" -ORIGINAL_SWITCH: Final[str] = "reconstructions.reconstruction.label.original_audio_radio" -RECONSTRUCTION_SWITCH: Final[str] = "reconstructions.reconstruction.label.reconstruction_radio" -ORIGINAL_LINE: Final[str] = "global.graph.label.waveform_original" -RECONSTRUCTION_LINE: Final[str] = "global.graph.label.waveform_reconstruction" - - -def entry(screen: Screen) -> str: - """What the Playback menu's first entry reads: Play while stopped, Pause while playing, Resume while paused.""" - return screen.sequencer.playback.play_entry() - - -def expect_entry(screen: Screen, key: str) -> None: - screen.expect(lambda: entry(screen), screen.words(key).__eq__, description=f"the Playback menu reading {key}") - - -def cursor(waveform: Waveform) -> float: - found = waveform.cursor() - assert found is not None, "no cursor stands on the waveform" - return found - - -def playing_from(screen: Screen, waveform: Waveform, sample: float) -> float: - """Waits for the cursor to stand at ``sample`` or a little past it, where playing from ``sample`` puts it.""" - window = PLAYING_WINDOW_SECONDS * SAMPLE_RATE - return screen.expect( - lambda: waveform.cursor() or -1.0, - lambda reading: sample <= reading < sample + window, - description=f"the cursor playing on from sample {sample:.0f}", - ) - - -def advancing(screen: Screen, waveform: Waveform, after: float) -> float: - return screen.expect( - lambda: waveform.cursor() or -1.0, - lambda reading: reading > after, - description=f"the cursor moving past sample {after:.0f}", - ) - - -def held_still(screen: Screen, waveform: Waveform) -> float: - """The cursor's sample, read twice some frames apart and found the same.""" - standing = cursor(waveform) - screen.frames(STILL_FRAMES) - assert waveform.cursor() == standing - return standing - - -def width(limits: Tuple[float, float]) -> float: - low, high = limits - return high - low - - -def silent_share(drawn: List[float]) -> float: - return sum(1 for height in drawn if abs(height) < SILENCE) / len(drawn) - - -class TestPlayingFromTheBrowser: - """A reconstruction opened from the browser plays on Space, its cursor moving along the waveform.""" - - def test_space_plays_and_the_cursor_moves_on(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - - def open_it(screen: Screen) -> None: - load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) - - expect_open(screen, PLAYABLE_RECONSTRUCTION) - assert waveform.cursor() is None - assert entry(screen) == screen.words(PLAY) - - def space_plays_it(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.PLAY) - - expect_entry(screen, PAUSE) - first = screen.expect(waveform.cursor, bool, description="the cursor") - assert first is not None - advancing(screen, waveform, first) - - screen.scenario(open_it, space_plays_it).run() - - -class TestTheWaveformGestures: - """A click plays from a point, moves the cursor while paused and jumps while playing; Space pauses and resumes. - - A drag pans the view and a double-click fits it, and neither moves the cursor or changes what plays. - """ - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_clicks_and_space_steer_playback(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - stood: List[float] = [] - - def a_click_while_stopped_plays_from_there(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - under = waveform.sample_under(0.5) - - waveform.click(0.5) - - expect_entry(screen, PAUSE) - playing_from(screen, waveform, under) - - def space_pauses_and_the_cursor_holds(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.PLAY) - - expect_entry(screen, RESUME) - screen.frames(STILL_FRAMES) - held_still(screen, waveform) - - def a_click_while_paused_moves_the_cursor_and_stays_paused(screen: Screen) -> None: - under = waveform.sample_under(0.25) - - waveform.click(0.25) - - screen.expect(waveform.cursor, float(round(under)).__eq__, description="the cursor where clicked") - stood.append(held_still(screen, waveform)) - assert entry(screen) == screen.words(RESUME) - - def space_resumes_from_the_cursor(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.PLAY) - - expect_entry(screen, PAUSE) - playing_from(screen, waveform, stood[0]) - - def a_click_while_playing_jumps_there(screen: Screen) -> None: - under = waveform.sample_under(0.75) - - waveform.click(0.75) - - playing_from(screen, waveform, under) - assert entry(screen) == screen.words(PAUSE) - - def stop_takes_the_cursor_away(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.STOP) - - expect_entry(screen, PLAY) - screen.expect(waveform.cursor, operator.not_, description="no cursor") - - screen.scenario( - a_click_while_stopped_plays_from_there, - space_pauses_and_the_cursor_holds, - a_click_while_paused_moves_the_cursor_and_stays_paused, - space_resumes_from_the_cursor, - a_click_while_playing_jumps_there, - stop_takes_the_cursor_away, - ).run() - - def test_a_drag_pans_and_a_double_click_fits_without_touching_playback(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - fitted: List[Tuple[float, float]] = [] - stood: List[float] = [] - - def pause_partway(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - fitted.append( - screen.expect(waveform.limits, lambda limits: limits[1] > SAMPLE_RATE, description="the view fitted") - ) - waveform.click(0.5) - expect_entry(screen, PAUSE) - - screen.press_shortcut(ShortcutId.PLAY) - - expect_entry(screen, RESUME) - screen.frames(STILL_FRAMES) - stood.append(held_still(screen, waveform)) - - def zoom_in_and_drag(screen: Screen) -> None: - waveform.zoom_in(0.5, ZOOM_NOTCHES) - zoomed = screen.expect( - waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed" - ) - - waveform.drag(0.6, 0.4) - - panned = screen.expect(waveform.limits, lambda limits: limits[0] > zoomed[0], description="panned") - assert abs(width(panned) - width(zoomed)) <= WIDTH_TOLERANCE * width(zoomed) - assert waveform.cursor() == stood[0] - assert entry(screen) == screen.words(RESUME) - - def a_double_click_fits_and_leaves_the_pause(screen: Screen) -> None: - waveform.double_click(0.5) - - screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") - screen.frames(STILL_FRAMES) - assert waveform.cursor() == stood[0] - assert entry(screen) == screen.words(RESUME) - - def a_double_click_leaves_playing_playing(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.PLAY) - expect_entry(screen, PAUSE) - waveform.zoom_in(0.5, ZOOM_NOTCHES) - screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") - before = cursor(waveform) - - waveform.double_click(0.5) - - screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") - assert entry(screen) == screen.words(PAUSE) - advancing(screen, waveform, before) - - def a_double_click_leaves_stopped_stopped(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.STOP) - expect_entry(screen, PLAY) - waveform.zoom_in(0.5, ZOOM_NOTCHES) - screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") - - waveform.double_click(0.5) - - screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") - screen.frames(STILL_FRAMES) - assert waveform.cursor() is None - assert entry(screen) == screen.words(PLAY) - - screen.scenario( - pause_partway, - zoom_in_and_drag, - a_double_click_fits_and_leaves_the_pause, - a_double_click_leaves_playing_playing, - a_double_click_leaves_stopped_stopped, - ).run() - - -class TestPressesThatAreNoClick: - """A triple click and a drag that leaves the plot play nothing, and the next plain click plays.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_a_triple_click_and_a_drag_off_the_plot_play_nothing(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - - def a_triple_click(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - - waveform.triple_click(0.5) - - screen.frames(STILL_FRAMES) - assert waveform.cursor() is None - assert entry(screen) == screen.words(PLAY) - - def a_drag_off_the_plot(screen: Screen) -> None: - waveform.zoom_in(0.5, ZOOM_NOTCHES) - - waveform.drag(0.5, PAST_THE_EDGE) - - screen.frames(STILL_FRAMES) - assert waveform.cursor() is None - assert entry(screen) == screen.words(PLAY) - - def a_plain_click_plays(screen: Screen) -> None: - under = waveform.sample_under(0.5) - - waveform.click(0.5) - - expect_entry(screen, PAUSE) - playing_from(screen, waveform, under) - screen.press_shortcut(ShortcutId.STOP) - expect_entry(screen, PLAY) - - screen.scenario(a_triple_click, a_drag_off_the_plot, a_plain_click_plays).run() - - -class TestABurstOfSpace: - """Five presses of Space from a stop play, pause, resume, pause and resume: the burst ends playing.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_an_odd_burst_ends_playing(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - expect_open(screen, PLAYABLE_RECONSTRUCTION) - - for _ in range(SPACE_BURST): - screen.press_shortcut(ShortcutId.PLAY) - - expect_entry(screen, PAUSE) - advancing(screen, waveform, cursor(waveform)) - screen.press_shortcut(ShortcutId.STOP) - expect_entry(screen, PLAY) - - -class TestTheSourceSwitch: - """The switch above the waveform puts the source it names on top of the waveform, and back.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_the_source_chosen_is_drawn_on_top(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - waveform = reconstructions.waveform - original = waveform.series_tag(screen.words(ORIGINAL_LINE)) - reconstruction = waveform.series_tag(screen.words(RECONSTRUCTION_LINE)) - - def the_reconstruction_on_top(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - - assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) - assert waveform.series() == (original, reconstruction) - - def the_original_chosen(screen: Screen) -> None: - reconstructions.choose_audio_source(screen.words(ORIGINAL_SWITCH)) - - screen.expect(waveform.series, (reconstruction, original).__eq__, description="the original on top") - - def the_reconstruction_chosen_again(screen: Screen) -> None: - reconstructions.choose_audio_source(screen.words(RECONSTRUCTION_SWITCH)) - - screen.expect(waveform.series, (original, reconstruction).__eq__, description="the reconstruction on top") - - screen.scenario(the_reconstruction_on_top, the_original_chosen, the_reconstruction_chosen_again).run() - - -class TestTheSourceSwitchWithItsRecordingGone: - """A reconstruction whose recording is gone offers the reconstruction alone.""" - - @pytest.fixture - def world(self) -> World: - world = playing_world() - gone = Path.cwd() / TAKES[0] - return World( - state=world.state, - application_config=world.application_config, - config=world.config, - files=tuple(file for file in world.files if not (isinstance(file, Recording) and file.destination == gone)), - ) - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_the_switch_stays_on_the_reconstruction(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - notice = screen.file_not_found_notice - - def the_missing_recording_is_named(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - screen.expect(notice.is_shown, bool, description="the notice about the recording") - - assert str(Path.cwd() / TAKES[0]) in notice.words() - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") - - def the_switch_offers_nothing_else(screen: Screen) -> None: - assert not reconstructions.can_choose_audio_source() - assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) - - screen.scenario(the_missing_recording_is_named, the_switch_offers_nothing_else).run() - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: a reconstruction whose recording is missing draws a flat original line", - ) - def test_the_waveform_draws_the_reconstruction_alone(self, screen: Screen) -> None: - waveform = screen.reconstructions.waveform - notice = screen.file_not_found_notice - expect_open(screen, PLAYABLE_RECONSTRUCTION) - screen.expect(notice.is_shown, bool, description="the notice about the recording") - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") - - assert waveform.series() == (waveform.series_tag(screen.words(RECONSTRUCTION_LINE)),) - - -class TestAnotherReconstructionAfterClosing: - """A reconstruction opened after another was closed draws its cursor as it plays.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_its_cursor_moves_on(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - waveform = reconstructions.waveform - - def play_and_close_the_first(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - screen.press_shortcut(ShortcutId.PLAY) - expect_entry(screen, PAUSE) - screen.press_shortcut(ShortcutId.STOP) - expect_entry(screen, PLAY) - - reconstructions.close_from_menu() - - screen.expect(reconstructions.file_line, operator.not_, description="nothing open") - - def open_the_second_and_play(screen: Screen) -> None: - load_from_the_browser(screen, SECOND_PLAYABLE) - expect_open(screen, SECOND_PLAYABLE) - - screen.press_shortcut(ShortcutId.PLAY) - - expect_entry(screen, PAUSE) - first = screen.expect(waveform.cursor, bool, description="the cursor") - assert first is not None - advancing(screen, waveform, first) - screen.press_shortcut(ShortcutId.STOP) - expect_entry(screen, PLAY) - - screen.scenario(play_and_close_the_first, open_the_second_and_play).run() - - -class TestChoosingChannels: - """The waveform draws the channels ticked above it, and ticking one back draws what it drew before.""" - - @pytest.fixture - def startup(self) -> Startup: - return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) - - def test_the_beat_alone_falls_silent_between_its_strokes(self, screen: Screen) -> None: - reconstructions = screen.reconstructions - waveform = reconstructions.waveform - line = screen.words(RECONSTRUCTION_LINE) - drawn: Dict[str, List[float]] = {} - - def every_channel_sounds_throughout(screen: Screen) -> None: - expect_open(screen, PLAYABLE_RECONSTRUCTION) - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - - drawn["all"] = waveform.drawn(line) - - assert silent_share(drawn["all"]) == 0.0 - - def the_beat_alone(screen: Screen) -> None: - for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): - reconstructions.tick_channel(channel) - - beat = screen.expect( - lambda: waveform.drawn(line), - drawn["all"].__ne__, - description="the waveform redrawn", - ) - assert abs(silent_share(beat) - (BEAT_FRAMES - BEAT_LENGTH) / BEAT_FRAMES) <= SILENT_SHARE_TOLERANCE - - def every_channel_back(screen: Screen) -> None: - for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): - reconstructions.tick_channel(channel) - - screen.expect(lambda: waveform.drawn(line), drawn["all"].__eq__, description="the waveform as before") - - screen.scenario(every_channel_sounds_throughout, the_beat_alone, every_channel_back).run() diff --git a/tests/screens/sequencer/colors/__init__.py b/tests/screens/sequencer/colors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/test_colors.py b/tests/screens/sequencer/colors/test_kind_colors.py similarity index 87% rename from tests/screens/sequencer/test_colors.py rename to tests/screens/sequencer/colors/test_kind_colors.py index fb0775e13..30a31079b 100644 --- a/tests/screens/sequencer/test_colors.py +++ b/tests/screens/sequencer/colors/test_kind_colors.py @@ -2,8 +2,6 @@ from functools import partial from typing import Final, List, Optional, Tuple -import pytest - from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME @@ -11,13 +9,12 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import NOTE_OFF -from tests.suite.screens.application import Startup -from tests.suite.screens.dearpygui.items import rounded_color +from tests.suite.screens.dearpygui.items.colors import rounded_color from tests.suite.screens.palettes import Color, shipped_palettes, token_color from tests.suite.screens.screen import Screen from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go from tests.suite.screens.views.history import HistoryLine -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_ROW, BASS_VOICE, LINE, PAD, PAD_ROW +from tests.suite.screens.worlds.songs import BASS_ROW, BASS_VOICE, LINE, PAD, PAD_ROW SAMPLE_TOKEN: Final[str] = "voice_sample" INSTRUMENT_TOKEN: Final[str] = "voice_instrument" @@ -37,22 +34,20 @@ POSITION_MARK: Final[str] = ":" -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - def type_into(screen: Screen, row: int, channel: ChannelName, text: str) -> None: + """Clicks the voice slot of a channel at a row, types the text and lets the typing settle.""" screen.sequencer.tracker.click(row, channel, SubColumn.VOICE) screen.hand.type_text(text) screen.frames(TYPING_FRAMES) def kind_mark(screen: Screen, voice: str) -> Color: + """The rounded color of the kind mark beside the voice called ``voice`` in the list.""" return rounded_color(screen.sequencer.voices.kind_color(voice)) def voice_color(screen: Screen, row: int, channel: ChannelName) -> Optional[Color]: + """The text color of a channel's voice cell at a row, or None when the cell shows none.""" return screen.sequencer.tracker.text_color(row, channel, SubColumn.VOICE) @@ -69,12 +64,13 @@ def is_dimmed(color: Optional[Color], full: Color) -> bool: class TestKindColorsInEveryPalette: - """A recording reads in the sample color and a hand-written voice in the instrument color, as kind marks and as - tracker cells, in every palette and live on a swap; an empty cell and a cut read neutral, and a muted channel's - cells keep their hue, fainter. + """A recording reads in the sample color and a hand-written voice in the instrument color, as kind marks and as tracker cells, in every palette and live on a swap. + + An empty cell and a cut read neutral, and a muted channel's cells keep their hue, fainter. The scenario cuts a cell and mutes the triangle, then checks the default palette and every shipped palette in Display settings. """ def test_marks_and_cells_take_every_palette(self, screen: Screen) -> None: + """Marks and cells take the colors of each palette as it is chosen.""" tracker = screen.sequencer.tracker settings = screen.display_settings catalog = shipped_palettes() @@ -135,11 +131,13 @@ def every_palette_paints_the_kinds(screen: Screen) -> None: class TestColorsFollowTheVoice: - """A voice's color travels with it: a placement's history line names it in its color, a move re-numbers and - re-colors its cells and leaves the others alone, and the lines naming it keep its color once it is removed. + """A voice's color travels with it: a placement's history line names it in its color, a move re-numbers and re-colors its cells and leaves the others alone, and the lines naming it keep its color once it is removed. + + An instrument is placed, moved up one place and removed after a confirmation. """ def test_placed_moved_and_removed(self, screen: Screen) -> None: + """Each step shows the instrument's number and color in the cells and in the history.""" tracker = screen.sequencer.tracker voices = screen.sequencer.voices history = screen.sequencer.history diff --git a/tests/screens/sequencer/conftest.py b/tests/screens/sequencer/conftest.py index e191b2922..ee0964be9 100644 --- a/tests/screens/sequencer/conftest.py +++ b/tests/screens/sequencer/conftest.py @@ -1,9 +1,17 @@ import pytest -from tests.suite.screens.world import World, sequencer_world +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world @pytest.fixture def world() -> World: """The home every Sequencer scenario starts in: an arranged project, and reconstructions to add to it.""" return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on the arranged project with no reconstruction loaded.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/sequencer/history/__init__.py b/tests/screens/sequencer/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/history/cases.py b/tests/screens/sequencer/history/cases.py new file mode 100644 index 000000000..c9ef45e4c --- /dev/null +++ b/tests/screens/sequencer/history/cases.py @@ -0,0 +1,46 @@ +import operator +from functools import partial +from typing import Callable, Final, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.steps import pick, rename +from tests.suite.screens.screen import Screen + +DUPLICATE: Final[str] = "sequencer.voices.label.context_duplicate" +Gesture = Callable[[Screen, str], None] + + +def duplicate(screen: Screen, name: str) -> None: + """Duplicates the voice ``name`` from the context menu of its row.""" + voices = screen.sequencer.voices + menu = screen.context_menu + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(screen.expect_item(partial(voices.row, name), description=f"the row of {name}")) + screen.expect(menu.is_shown, bool, description="the row's menu") + menu.choose(screen.words(DUPLICATE)) + + +def move(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place, down where a place lies below it and up otherwise.""" + last = screen.sequencer.voices.names()[-1] == name + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP if last else ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def remove(screen: Screen, name: str) -> None: + """Removes the voice ``name`` by shortcut and confirms the question about removing.""" + prompt = screen.sequencer.voices.remove_prompt + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + prompt.confirm() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + + +GESTURES: Final[Tuple[Tuple[str, Gesture], ...]] = ( + ("rename", rename), + ("duplicate", duplicate), + ("move", move), + ("remove", remove), +) diff --git a/tests/screens/sequencer/history/constants.py b/tests/screens/sequencer/history/constants.py new file mode 100644 index 000000000..36ae797f6 --- /dev/null +++ b/tests/screens/sequencer/history/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 +RENAMED: Final[str] = "Renamed" +PAD_POSITION: Final[str] = "02" +POSITION_MARK: Final[str] = ":" diff --git a/tests/screens/sequencer/history/steps.py b/tests/screens/sequencer/history/steps.py new file mode 100644 index 000000000..c0ac94f4d --- /dev/null +++ b/tests/screens/sequencer/history/steps.py @@ -0,0 +1,32 @@ +from functools import partial + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.constants import RENAMED, SETTLING_FRAMES +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.suite.screens.screen import Screen + + +def pick(screen: Screen, name: str) -> None: + """Opens the Sequencer tab, picks the row of the voice ``name`` and lets the screen settle.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + row = screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + voices.pick(row) + screen.frames(SETTLING_FRAMES) + + +def rename(screen: Screen, name: str) -> None: + """Renames the voice ``name`` to the fixed new name: picks it, edits it and confirms.""" + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) diff --git a/tests/screens/sequencer/history/test_envelope_drag.py b/tests/screens/sequencer/history/test_envelope_drag.py new file mode 100644 index 000000000..85b01dd6f --- /dev/null +++ b/tests/screens/sequencer/history/test_envelope_drag.py @@ -0,0 +1,70 @@ +from typing import Final, List, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.sequencer.history.constants import PAD_POSITION, POSITION_MARK +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice +from tests.suite.screens.views.history import HistoryLine +from tests.suite.screens.worlds.songs import PAD + +VOLUME_LETTER: Final[str] = "v" +ARPEGGIO_LETTER: Final[str] = "a" +QUIET: Final[float] = 4.0 +ARPEGGIO_STEP: Final[float] = 5.0 +DRAGGED_ITEM: Final[int] = 1 + + +def position_color(line: HistoryLine, position: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice by its ``position``.""" + return next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + + +class TestAnEnvelopeDragIsOneEntry: + """A drag across an instrument's envelope makes one history entry naming the voice and the dimension. + + The pad instrument is opened and its volume envelope is dragged: one entry appears, naming the + instrument by its number in the color of its kind. A drag across the arpeggio envelope adds a second + entry. One undo takes back the arpeggio drag only, and the project is left. + """ + + def test_one_entry_per_dimension(self, screen: Screen) -> None: + """Each drag makes exactly one entry, and one undo reverts one drag.""" + instruments = screen.reconstructions.instruments + history = screen.sequencer.history + lines: List[int] = [] + standing: List[str] = [] + + def drag(feature: FeatureKey, end: float) -> None: + graph = instruments.graph(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(graph.plot) + graph.drag_item(DRAGGED_ITEM, start=0.0, end=end) + + def drag_the_volume(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + lines.append(len(history.lines())) + + drag(FeatureKey.VOLUME, QUIET) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 1, description="one entry") + assert position_color(after[0], PAD_POSITION) == screen.sequencer.voices.kind_color(PAD) + assert VOLUME_LETTER in after[0].words.split() + + def drag_the_arpeggio(screen: Screen) -> None: + standing.append(instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO)) + + drag(FeatureKey.ARPEGGIO, ARPEGGIO_STEP) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 2, description="a second entry") + assert ARPEGGIO_LETTER in after[0].words.split() + + def one_undo_takes_one_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(lambda: history.lines()[1].current, bool, description="one step back") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) == standing[0] + + screen.scenario(drag_the_volume, drag_the_arpeggio, one_undo_takes_one_back, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/test_history.py b/tests/screens/sequencer/history/test_voice_gestures.py similarity index 51% rename from tests/screens/sequencer/test_history.py rename to tests/screens/sequencer/history/test_voice_gestures.py index 634242d57..f0001abc0 100644 --- a/tests/screens/sequencer/test_history.py +++ b/tests/screens/sequencer/history/test_voice_gestures.py @@ -1,103 +1,29 @@ -import operator -from functools import partial from typing import Callable, Final, List, Tuple import pytest from sampletones_application.categories.hierarchy import Tab -from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.screens.application import Startup -from tests.suite.screens.dearpygui.items import read_item -from tests.suite.screens.dearpygui.keys import IMGUI_ENTER +from tests.screens.sequencer.history.cases import GESTURES, Gesture +from tests.screens.sequencer.history.constants import PAD_POSITION, POSITION_MARK +from tests.screens.sequencer.history.steps import pick, rename from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import titled -from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go, open_voice +from tests.suite.screens.steps.sequencer import forgive_the_hover_race from tests.suite.screens.views.history import HistoryLine -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD - -SETTLING_FRAMES: Final[int] = 10 -RENAMED: Final[str] = "Renamed" -DUPLICATE: Final[str] = "sequencer.voices.label.context_duplicate" -VOLUME_LETTER: Final[str] = "v" -ARPEGGIO_LETTER: Final[str] = "a" -QUIET: Final[float] = 4.0 -ARPEGGIO_STEP: Final[float] = 5.0 -DRAGGED_ITEM: Final[int] = 1 -LINE_POSITION: Final[str] = "00" -PAD_POSITION: Final[str] = "02" -POSITION_MARK: Final[str] = ":" - -Gesture = Callable[[Screen, str], None] - - -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - -def pick(screen: Screen, name: str) -> None: - voices = screen.sequencer.voices - screen.tabs.bring_to_front(Tab.SEQUENCER) - row = screen.expect_item(partial(voices.row, name), description=f"the row of {name}") - voices.pick(row) - screen.frames(SETTLING_FRAMES) - - -def rename(screen: Screen, name: str) -> None: - pick(screen, name) - screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) - screen.expect( - lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, - bool, - description="the name being edited", - ) - - screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) - screen.hand.press_key(IMGUI_ENTER, modifiers=[]) +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD - -def duplicate(screen: Screen, name: str) -> None: - voices = screen.sequencer.voices - menu = screen.context_menu - screen.tabs.bring_to_front(Tab.SEQUENCER) - voices.right_click(screen.expect_item(partial(voices.row, name), description=f"the row of {name}")) - screen.expect(menu.is_shown, bool, description="the row's menu") - menu.choose(screen.words(DUPLICATE)) +LINE_POSITION: Final[str] = "00" def move_down(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place down.""" pick(screen, name) screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_DOWN) -def move(screen: Screen, name: str) -> None: - """Moves the voice ``name`` one place, down where a place lies below it and up otherwise.""" - last = screen.sequencer.voices.names()[-1] == name - pick(screen, name) - screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP if last else ShortcutId.VOICES_MOVE_VOICE_DOWN) - - -def remove(screen: Screen, name: str) -> None: - prompt = screen.sequencer.voices.remove_prompt - pick(screen, name) - screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) - screen.expect(prompt.is_shown, bool, description="the question about removing") - prompt.confirm() - screen.expect(prompt.is_shown, operator.not_, description="the question answered") - - -GESTURES: Final[Tuple[Tuple[str, Gesture], ...]] = ( - ("rename", rename), - ("duplicate", duplicate), - ("move", move), - ("remove", remove), -) - - def done(lines: Tuple[HistoryLine, ...]) -> Tuple[HistoryLine, ...]: - """The lines of the entries done, from the one in force down, which leaves out those an undo set aside.""" + """The lines of the entries done, from the one in force down, leaving out those an undo set aside.""" current = next(index for index, line in enumerate(lines) if line.current) return lines[current:] @@ -107,11 +33,6 @@ def voice_segment_color(line: HistoryLine, name: str) -> Tuple[float, ...]: return next(segment.color for segment in line.segments if name in segment.words) -def position_color(line: HistoryLine, position: str) -> Tuple[float, ...]: - """The color of the piece of ``line`` naming the voice by its ``position``.""" - return next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) - - def naming_color(line: HistoryLine, voice: str, position: str) -> Tuple[float, ...]: """The color of the first piece of ``line`` naming the voice, by its name or by its ``position``.""" return next( @@ -127,12 +48,13 @@ def one_more_than(count: int) -> Callable[[Tuple[HistoryLine, ...]], bool]: def names_by_position_and_name(line: HistoryLine, voice: str, position: str) -> bool: + """Whether ``line`` names the voice both by its ``position`` and by its name.""" words = [segment.words.rstrip(POSITION_MARK) for segment in line.segments] return position in words and any(voice in word for word in words) def one_entry_per_gesture(gesture_name: str, gesture: Gesture, voice: str, position: str) -> Callable[[Screen], None]: - """The step making ``gesture`` on ``voice``: one entry naming it in its kind's color, undone and redone at once.""" + """The step making ``gesture`` on ``voice``: one entry in its kind's color, undone and redone.""" def step(screen: Screen) -> None: voices = screen.sequencer.voices @@ -163,7 +85,7 @@ def step(screen: Screen) -> None: def leave_as_opened(screen: Screen) -> None: - """Exits a project every gesture of which was undone, which leaves at once.""" + """Exits a project whose gestures were all undone; the window closes at once.""" forgive_the_hover_race(screen) assert screen.title() == titled(screen, ARRANGED_PROJECT.stem) @@ -175,11 +97,13 @@ def leave_as_opened(screen: Screen) -> None: class TestEachVoiceGestureIsOneEntry: """Renaming, duplicating, moving and removing a sample or an instrument each make one entry. - The entry names the voice in the color of its kind, and one undo and one redo take it back and - forth. + The entry names the voice in the color of its kind, and one undo and one redo take it back and forth. """ def test_a_sample_and_an_instrument(self, screen: Screen) -> None: + """Each of the four gestures on the sample and on the pad instrument adds one entry, and undo, redo and + undo again move the voice list back and forth. + """ steps = [ one_entry_per_gesture(name, gesture, voice, position) for voice, position in ((LINE, LINE_POSITION), (PAD, PAD_POSITION)) @@ -194,6 +118,7 @@ def test_a_sample_and_an_instrument(self, screen: Screen) -> None: reason="bugs-and-todos ยง Bugs: the history lines of a renamed or a moved voice name it one way alone", ) def test_every_line_names_the_voice_by_position_and_name(self, screen: Screen) -> None: + """The entries of a renamed and of a moved sample each name it by its position and by its name.""" history = screen.sequencer.history lines: List[HistoryLine] = [] for gesture in (rename, move_down): @@ -209,6 +134,7 @@ def test_every_line_names_the_voice_by_position_and_name(self, screen: Screen) - assert all(names_by_position_and_name(line, LINE, LINE_POSITION) for line in lines) def test_a_new_instrument_is_one_entry(self, screen: Screen) -> None: + """A new instrument adds one entry in the instrument color, and one undo removes the instrument.""" voices = screen.sequencer.voices history = screen.sequencer.history @@ -230,50 +156,3 @@ def undo_takes_it_away(screen: Screen) -> None: screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the instrument gone") screen.scenario(add_one, undo_takes_it_away, leave_as_opened).run() - - -class TestAnEnvelopeDragIsOneEntry: - """A drag across an instrument's envelope makes one entry naming the voice and the dimension; another, another. - - The entry names the instrument by its number, in the color of its kind. - """ - - def test_one_entry_per_dimension(self, screen: Screen) -> None: - instruments = screen.reconstructions.instruments - history = screen.sequencer.history - lines: List[int] = [] - standing: List[str] = [] - - def drag(feature: FeatureKey, end: float) -> None: - graph = instruments.graph(ChannelName.PULSE1, feature) - screen.hand.scroll_into_view(graph.plot) - graph.drag_item(DRAGGED_ITEM, start=0.0, end=end) - - def drag_the_volume(screen: Screen) -> None: - open_voice(screen, PAD) - screen.expect(instruments.offers_audition, bool, description="the instrument open") - lines.append(len(history.lines())) - - drag(FeatureKey.VOLUME, QUIET) - - after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 1, description="one entry") - assert position_color(after[0], PAD_POSITION) == screen.sequencer.voices.kind_color(PAD) - assert VOLUME_LETTER in after[0].words.split() - - def drag_the_arpeggio(screen: Screen) -> None: - standing.append(instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO)) - - drag(FeatureKey.ARPEGGIO, ARPEGGIO_STEP) - - after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 2, description="a second entry") - assert ARPEGGIO_LETTER in after[0].words.split() - - def one_undo_takes_one_back(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - - history.undo() - - screen.expect(lambda: history.lines()[1].current, bool, description="one step back") - assert instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) == standing[0] - - screen.scenario(drag_the_volume, drag_the_arpeggio, one_undo_takes_one_back, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/song/__init__.py b/tests/screens/sequencer/song/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/song/constants.py b/tests/screens/sequencer/song/constants.py new file mode 100644 index 000000000..0ef134494 --- /dev/null +++ b/tests/screens/sequencer/song/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 20 +EMPTY_VOICE: Final[str] = ".." +LINE_NUMBER: Final[str] = "00" diff --git a/tests/screens/sequencer/song/test_master_gain.py b/tests/screens/sequencer/song/test_master_gain.py new file mode 100644 index 000000000..7e001860f --- /dev/null +++ b/tests/screens/sequencer/song/test_master_gain.py @@ -0,0 +1,69 @@ +import math +import operator +from typing import Final, List, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + +UNITY: Final[float] = 1.0 +LOUDEST_GAIN: Final[float] = 2.0 +BEYOND_THE_LEFT: Final[float] = -0.5 +BEYOND_THE_RIGHT: Final[float] = 1.5 +NEAR_THE_RIGHT: Final[float] = 0.97 +FROM_THE_MIDDLE: Final[float] = 0.5 +DECIBELS_PER_DOUBLING: Final[float] = 20.0 +SILENT_GAIN: Final[str] = "settings.audio.message.master_gain_silent" +GAIN_TEMPLATE: Final[str] = "settings.audio.template.master_gain_db" + + +class TestTheMasterGain: + """The decibel line of Audio settings follows the master gain slider. + + The dialog opens at unity. Dragging the slider past the left end reads silence in the unity color; + dragging past the right end reads the top level in the warning color; dragging back to the middle + reads that level in decibels, and Cancel closes the dialog. + """ + + def test_the_line_follows_the_slider(self, screen: Screen) -> None: + """The decibel line shows silence, the top level and a middle level as the slider moves.""" + settings = screen.audio_settings + unity_color: List[Tuple[float, ...]] = [] + + def open_audio_settings(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) + screen.expect(settings.is_shown, bool, description="Audio settings") + + assert settings.gain() == UNITY + unity_color.append(settings.decibels_color()) + + def all_the_way_down_reads_silence(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_LEFT) + + screen.expect(settings.gain, (0.0).__eq__, description="the gain at zero") + assert settings.decibels() == screen.words(SILENT_GAIN) + assert settings.decibels_color() == unity_color[0] + + def all_the_way_up_reads_the_level_in_warning(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_RIGHT) + + screen.expect(settings.gain, LOUDEST_GAIN.__eq__, description="the gain at its top") + level = DECIBELS_PER_DOUBLING * math.log10(LOUDEST_GAIN) + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format(decibels=level) + assert settings.decibels_color() != unity_color[0] + + def back_to_unity(screen: Screen) -> None: + settings.drag_gain(NEAR_THE_RIGHT, FROM_THE_MIDDLE) + + gain = screen.expect(settings.gain, lambda found: 0.0 < found < LOUDEST_GAIN, description="a middle gain") + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format( + decibels=DECIBELS_PER_DOUBLING * math.log10(gain) + ) + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + + screen.scenario( + open_audio_settings, + all_the_way_down_reads_silence, + all_the_way_up_reads_the_level_in_warning, + back_to_unity, + ).run() diff --git a/tests/screens/sequencer/song/test_order_and_highlights.py b/tests/screens/sequencer/song/test_order_and_highlights.py new file mode 100644 index 000000000..da9ad057b --- /dev/null +++ b/tests/screens/sequencer/song/test_order_and_highlights.py @@ -0,0 +1,89 @@ +import operator +from functools import partial +from typing import Final + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.song.constants import EMPTY_VOICE, LINE_NUMBER +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer + +FIRST_PATTERN: Final[str] = "00" +SECOND_PATTERN: Final[str] = "01" +FIRST_HIGHLIGHT: Final[int] = 3 +SECOND_HIGHLIGHT: Final[int] = 6 +TINTED_ROWS_READ: Final[int] = 13 + + +class TestTheOrderTable: + """Typing an order entry names the pattern a channel plays at that frame, and the tracker follows it. + + Pattern 01 typed on the first frame of the first pulse channel empties its first line; typing 00 again + brings the line back. + """ + + def test_patterns_follow_the_order(self, screen: Screen) -> None: + """The tracker shows the pattern the order entry names.""" + order = screen.sequencer.order + tracker = screen.sequencer.tracker + + def a_new_pattern_number_shows_a_new_pattern(screen: Screen) -> None: + on_the_sequencer(screen) + assert order.label(ChannelName.PULSE1, 0) == FIRST_PATTERN + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(SECOND_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), SECOND_PATTERN.__eq__, description="pattern 01") + assert tracker.label(0, ChannelName.PULSE1, SubColumn.VOICE) == EMPTY_VOICE + + def the_first_number_brings_the_line_back(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(FIRST_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), FIRST_PATTERN.__eq__, description="pattern 00") + screen.expect( + partial(tracker.label, 0, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the line back", + ) + + screen.scenario( + a_new_pattern_number_shows_a_new_pattern, + the_first_number_brings_the_line_back, + leave_letting_the_project_go, + ).run() + + +class TestTheHighlights: + """The tracker tints the rows the project's highlights name. + + Highlights of three and six are typed in Project properties; every third row ends up tinted. + """ + + def test_rows_on_the_highlight_are_tinted(self, screen: Screen) -> None: + """Exactly the rows on the first highlight are tinted.""" + properties = screen.project.properties + tracker = screen.sequencer.tracker + + def set_highlights_of_three_and_six(screen: Screen) -> None: + on_the_sequencer(screen) + properties.open() + screen.expect(properties.is_shown, bool, description="Project properties") + + properties.retype_highlights(FIRST_HIGHLIGHT, SECOND_HIGHLIGHT) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="Project properties closed") + + def every_third_row_is_tinted(screen: Screen) -> None: + expected = [row % FIRST_HIGHLIGHT == 0 for row in range(TINTED_ROWS_READ)] + + screen.expect( + lambda: [tracker.is_row_tinted(row) for row in range(TINTED_ROWS_READ)], + expected.__eq__, + description="every third row tinted", + ) + + screen.scenario(set_highlights_of_three_and_six, every_third_row_is_tinted, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/song/test_retuning_and_saving.py b/tests/screens/sequencer/song/test_retuning_and_saving.py new file mode 100644 index 000000000..d0597f037 --- /dev/null +++ b/tests/screens/sequencer/song/test_retuning_and_saving.py @@ -0,0 +1,107 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.sequencer.song.constants import LINE_NUMBER, SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import marked, raise_the_first_level, titled, voice_title +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer, open_voice +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, LINE + +LINE_ORDINAL: Final[int] = 0 +FIFTY_HERTZ: Final[int] = 50 +SIXTY_HERTZ: Final[int] = 60 +REFIT_TOLERANCE: Final[float] = 0.02 + + +class TestRetuningWithASampleOpen: + """A new NES frequency re-fits the sample open on the Reconstructions tab and keeps its edit. + + The edit is made on the noise, whose last frame is silent, so the sample keeps its frames and the + waveform stretches by the ratio of the two rates. The scenario opens the sample, raises a level, + retypes the frequency from 60 to 50 Hz, confirms the retune and expects the stretched waveform and + the same envelope. + """ + + def test_the_waveform_refits_and_the_edit_stays(self, screen: Screen) -> None: + """The waveform stretches by the ratio of the rates and the edited envelope stays.""" + module = screen.sequencer.module + reconstructions = screen.reconstructions + typed: List[str] = [] + limits: List[Tuple[float, float]] = [] + + def edit_the_line(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=False).__eq__, + description="the line open", + ) + + typed.append( + raise_the_first_level( + screen, + ChannelName.NOISE, + title=voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=True), + ) + ) + limits.append(reconstructions.waveform.limits()) + + def retune_to_fifty(screen: Screen) -> None: + on_the_sequencer(screen) + assert module.nes_frequency() == SIXTY_HERTZ + + module.retype_nes_frequency(FIFTY_HERTZ) + + screen.expect(module.retune_prompt.is_shown, bool, description="the question about retuning") + module.retune_prompt.confirm() + screen.expect(module.retune_prompt.is_shown, operator.not_, description="the question answered") + + def the_waveform_refits_and_the_edit_stays(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + low, high = limits[0] + + refitted = screen.expect( + reconstructions.waveform.limits, + lambda found: abs(found[1] / high - SIXTY_HERTZ / FIFTY_HERTZ) <= REFIT_TOLERANCE, + description="the waveform re-fitted to the longer frames", + ) + assert refitted[0] == low + assert reconstructions.instruments.envelope(ChannelName.NOISE, FeatureKey.VOLUME) == typed[0] + + screen.scenario( + edit_the_line, retune_to_fifty, the_waveform_refits_and_the_edit_stays, leave_letting_the_project_go + ).run() + + +class TestSavingAProjectOpenedAtStart: + """Save writes a project opened at start to its own file and goes through at once. + + A cell is edited so the project is marked unsaved, then Save is pressed. The scenario expects the + saved notice and no file dialog. + """ + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: a project opened at start is saved as though it had no file", + ) + def test_save_writes_its_file(self, screen: Screen) -> None: + """Save shows the saved notice and opens no dialog.""" + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(LINE_NUMBER) + screen.expect( + screen.title, titled(screen, marked(ARRANGED_PROJECT.stem, unsaved=True)).__eq__, description="changed" + ) + + screen.press_shortcut(ShortcutId.SAVE_PROJECT) + + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests() == () + screen.expect(screen.project.saved_notice.is_shown, bool, description="the project saved notice") diff --git a/tests/screens/sequencer/song/test_volume_without_a_sample.py b/tests/screens/sequencer/song/test_volume_without_a_sample.py new file mode 100644 index 000000000..2c646c537 --- /dev/null +++ b/tests/screens/sequencer/song/test_volume_without_a_sample.py @@ -0,0 +1,51 @@ +from typing import Final, List, Optional, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK +from tests.screens.sequencer.song.constants import EMPTY_VOICE, SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer + +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None + + +class TestAVolumeWhereNoSamplePlays: + """A volume typed in the Sample column of an empty frame keeps the cell blank, adds no history entry and + creates no pattern. + + An empty second frame is added, a volume is typed on its first row, and the scenario expects the cell, + the history and the order entries of that frame to stay as they were. + """ + + def test_the_frame_stays_empty(self, screen: Screen) -> None: + """The frame stays empty after the volume is typed.""" + order = screen.sequencer.order + tracker = screen.sequencer.tracker + history = screen.sequencer.history + before: List[int] = [] + + def add_an_empty_frame(screen: Screen) -> None: + on_the_sequencer(screen) + order.click(ChannelName.PULSE1, 0) + + screen.press_shortcut(ShortcutId.ORDER_ADD_FRAME) + + screen.expect(order.positions, (2).__eq__, description="a second frame") + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + def type_a_volume_on_it(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 1) + before.append(len(history.lines())) + tracker.click(0, SAMPLE_COLUMN, SubColumn.VOLUME) + + screen.hand.type_text("5") + + screen.frames(SETTLING_FRAMES) + assert tracker.label(0, SAMPLE_COLUMN, SubColumn.VOLUME) == BLANK + assert len(history.lines()) == before[0] + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + screen.scenario(add_an_empty_frame, type_a_volume_on_it, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/test_song.py b/tests/screens/sequencer/test_song.py deleted file mode 100644 index 475c6e145..000000000 --- a/tests/screens/sequencer/test_song.py +++ /dev/null @@ -1,276 +0,0 @@ -import math -import operator -from functools import partial -from typing import Final, List, Optional, Tuple - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.utils.display import BLANK -from tests.suite.screens.application import Startup -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.reconstructions import marked, raise_the_first_level, titled, voice_title -from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, open_voice -from tests.suite.screens.world import ARRANGED_PROJECT, LINE - -SETTLING_FRAMES: Final[int] = 20 -EMPTY_VOICE: Final[str] = ".." -FIRST_PATTERN: Final[str] = "00" -SECOND_PATTERN: Final[str] = "01" -LINE_NUMBER: Final[str] = "00" -FIRST_HIGHLIGHT: Final[int] = 3 -SECOND_HIGHLIGHT: Final[int] = 6 -TINTED_ROWS_READ: Final[int] = 13 -UNITY: Final[float] = 1.0 -LOUDEST_GAIN: Final[float] = 2.0 -BEYOND_THE_LEFT: Final[float] = -0.5 -BEYOND_THE_RIGHT: Final[float] = 1.5 -NEAR_THE_RIGHT: Final[float] = 0.97 -FROM_THE_MIDDLE: Final[float] = 0.5 -DECIBELS_PER_DOUBLING: Final[float] = 20.0 -LINE_ORDINAL: Final[int] = 0 -FIFTY_HERTZ: Final[int] = 50 -SIXTY_HERTZ: Final[int] = 60 -REFIT_TOLERANCE: Final[float] = 0.02 -SILENT_GAIN: Final[str] = "settings.audio.message.master_gain_silent" -GAIN_TEMPLATE: Final[str] = "settings.audio.template.master_gain_db" -EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) -SAMPLE_COLUMN: Final[Optional[ChannelName]] = None - - -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - -def on_the_sequencer(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") - - -class TestTheOrderTable: - """An order entry typed names the pattern a channel plays at that frame, and the tracker follows it.""" - - def test_patterns_follow_the_order(self, screen: Screen) -> None: - order = screen.sequencer.order - tracker = screen.sequencer.tracker - - def a_new_pattern_number_shows_a_new_pattern(screen: Screen) -> None: - on_the_sequencer(screen) - assert order.label(ChannelName.PULSE1, 0) == FIRST_PATTERN - order.click(ChannelName.PULSE1, 0) - - screen.hand.type_text(SECOND_PATTERN) - - screen.expect(partial(order.label, ChannelName.PULSE1, 0), SECOND_PATTERN.__eq__, description="pattern 01") - assert tracker.label(0, ChannelName.PULSE1, SubColumn.VOICE) == EMPTY_VOICE - - def the_first_number_brings_the_line_back(screen: Screen) -> None: - order.click(ChannelName.PULSE1, 0) - - screen.hand.type_text(FIRST_PATTERN) - - screen.expect(partial(order.label, ChannelName.PULSE1, 0), FIRST_PATTERN.__eq__, description="pattern 00") - screen.expect( - partial(tracker.label, 0, ChannelName.PULSE1, SubColumn.VOICE), - LINE_NUMBER.__eq__, - description="the line back", - ) - - screen.scenario( - a_new_pattern_number_shows_a_new_pattern, - the_first_number_brings_the_line_back, - leave_letting_the_project_go, - ).run() - - -class TestAVolumeWhereNoSamplePlays: - """A volume typed in the Sample column of an empty frame keeps the cell, records nothing and makes no pattern.""" - - def test_the_frame_stays_empty(self, screen: Screen) -> None: - order = screen.sequencer.order - tracker = screen.sequencer.tracker - history = screen.sequencer.history - before: List[int] = [] - - def add_an_empty_frame(screen: Screen) -> None: - on_the_sequencer(screen) - order.click(ChannelName.PULSE1, 0) - - screen.press_shortcut(ShortcutId.ORDER_ADD_FRAME) - - screen.expect(order.positions, (2).__eq__, description="a second frame") - assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) - - def type_a_volume_on_it(screen: Screen) -> None: - order.click(ChannelName.PULSE1, 1) - before.append(len(history.lines())) - tracker.click(0, SAMPLE_COLUMN, SubColumn.VOLUME) - - screen.hand.type_text("5") - - screen.frames(SETTLING_FRAMES) - assert tracker.label(0, SAMPLE_COLUMN, SubColumn.VOLUME) == BLANK - assert len(history.lines()) == before[0] - assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) - - screen.scenario(add_an_empty_frame, type_a_volume_on_it, leave_letting_the_project_go).run() - - -class TestTheHighlights: - """The tracker tints the rows the project's highlights name, and those alone.""" - - def test_rows_on_the_highlight_are_tinted(self, screen: Screen) -> None: - properties = screen.project.properties - tracker = screen.sequencer.tracker - - def set_highlights_of_three_and_six(screen: Screen) -> None: - on_the_sequencer(screen) - properties.open() - screen.expect(properties.is_shown, bool, description="Project properties") - - properties.retype_highlights(FIRST_HIGHLIGHT, SECOND_HIGHLIGHT) - properties.confirm() - - screen.expect(properties.is_shown, operator.not_, description="Project properties closed") - - def every_third_row_is_tinted(screen: Screen) -> None: - expected = [row % FIRST_HIGHLIGHT == 0 for row in range(TINTED_ROWS_READ)] - - screen.expect( - lambda: [tracker.is_row_tinted(row) for row in range(TINTED_ROWS_READ)], - expected.__eq__, - description="every third row tinted", - ) - - screen.scenario(set_highlights_of_three_and_six, every_third_row_is_tinted, leave_letting_the_project_go).run() - - -class TestTheMasterGain: - """The decibel line follows the master gain: silence at the bottom, the level above unity in the warning color.""" - - def test_the_line_follows_the_slider(self, screen: Screen) -> None: - settings = screen.audio_settings - unity_color: List[Tuple[float, ...]] = [] - - def open_audio_settings(screen: Screen) -> None: - screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) - screen.expect(settings.is_shown, bool, description="Audio settings") - - assert settings.gain() == UNITY - unity_color.append(settings.decibels_color()) - - def all_the_way_down_reads_silence(screen: Screen) -> None: - settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_LEFT) - - screen.expect(settings.gain, (0.0).__eq__, description="the gain at zero") - assert settings.decibels() == screen.words(SILENT_GAIN) - assert settings.decibels_color() == unity_color[0] - - def all_the_way_up_reads_the_level_in_warning(screen: Screen) -> None: - settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_RIGHT) - - screen.expect(settings.gain, LOUDEST_GAIN.__eq__, description="the gain at its top") - level = DECIBELS_PER_DOUBLING * math.log10(LOUDEST_GAIN) - assert settings.decibels() == screen.words(GAIN_TEMPLATE).format(decibels=level) - assert settings.decibels_color() != unity_color[0] - - def back_to_unity(screen: Screen) -> None: - settings.drag_gain(NEAR_THE_RIGHT, FROM_THE_MIDDLE) - - gain = screen.expect(settings.gain, lambda found: 0.0 < found < LOUDEST_GAIN, description="a middle gain") - assert settings.decibels() == screen.words(GAIN_TEMPLATE).format( - decibels=DECIBELS_PER_DOUBLING * math.log10(gain) - ) - screen.press_shortcut(ShortcutId.DIALOG_CANCEL) - screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") - - screen.scenario( - open_audio_settings, - all_the_way_down_reads_silence, - all_the_way_up_reads_the_level_in_warning, - back_to_unity, - ).run() - - -class TestRetuningWithASampleOpen: - """A new NES frequency retunes the sample open on the Reconstructions tab: it re-fits, keeping the edit before. - - The edit is made on the noise, whose last frame is silent, so the sample keeps its frames and - the waveform stretches by the ratio of the two rates. - """ - - def test_the_waveform_refits_and_the_edit_stays(self, screen: Screen) -> None: - module = screen.sequencer.module - reconstructions = screen.reconstructions - typed: List[str] = [] - limits: List[Tuple[float, float]] = [] - - def edit_the_line(screen: Screen) -> None: - open_voice(screen, LINE) - screen.expect( - screen.title, - voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=False).__eq__, - description="the line open", - ) - - typed.append( - raise_the_first_level( - screen, - ChannelName.NOISE, - title=voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=True), - ) - ) - limits.append(reconstructions.waveform.limits()) - - def retune_to_fifty(screen: Screen) -> None: - on_the_sequencer(screen) - assert module.nes_frequency() == SIXTY_HERTZ - - module.retype_nes_frequency(FIFTY_HERTZ) - - screen.expect(module.retune_prompt.is_shown, bool, description="the question about retuning") - module.retune_prompt.confirm() - screen.expect(module.retune_prompt.is_shown, operator.not_, description="the question answered") - - def the_waveform_refits_and_the_edit_stays(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - low, high = limits[0] - - refitted = screen.expect( - reconstructions.waveform.limits, - lambda found: abs(found[1] / high - SIXTY_HERTZ / FIFTY_HERTZ) <= REFIT_TOLERANCE, - description="the waveform re-fitted to the longer frames", - ) - assert refitted[0] == low - assert reconstructions.instruments.envelope(ChannelName.NOISE, FeatureKey.VOLUME) == typed[0] - - screen.scenario( - edit_the_line, retune_to_fifty, the_waveform_refits_and_the_edit_stays, leave_letting_the_project_go - ).run() - - -class TestSavingAProjectOpenedAtStart: - """A project opened as the application starts is saved to its own file by Save, asking nothing.""" - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: a project opened at start is saved as though it had no file", - ) - def test_save_writes_its_file(self, screen: Screen) -> None: - on_the_sequencer(screen) - screen.sequencer.tracker.click(1, ChannelName.PULSE2, SubColumn.VOICE) - screen.hand.type_text(LINE_NUMBER) - screen.expect( - screen.title, titled(screen, marked(ARRANGED_PROJECT.stem, unsaved=True)).__eq__, description="changed" - ) - - screen.press_shortcut(ShortcutId.SAVE_PROJECT) - - screen.frames(SETTLING_FRAMES) - assert screen.dialog_requests() == () - screen.expect(screen.project.saved_notice.is_shown, bool, description="the project saved notice") diff --git a/tests/screens/sequencer/test_tracker.py b/tests/screens/sequencer/test_tracker.py deleted file mode 100644 index 0725dfa71..000000000 --- a/tests/screens/sequencer/test_tracker.py +++ /dev/null @@ -1,412 +0,0 @@ -import operator -from functools import partial -from typing import Final, List, Optional, Tuple - -import pytest - -from sampletones_application.categories.hierarchy import Tab -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import ChannelName -from sampletones_core.utils.display import BLANK, NOTE_BLANK, NOTE_OFF -from tests.suite.screens.application import Startup -from tests.suite.screens.dearpygui.items import read_label -from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A -from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.sequencer import leave_letting_the_project_go -from tests.suite.screens.views.tracker import tracker_cell, tracker_cell_theme -from tests.suite.screens.vocabulary.playback import PAUSE -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_ROW, PAD_ROW - -PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") -PIANO_C_UP: Final[int] = IMGUI_LETTER_A + ord("q") - ord("a") -TYPING_FRAMES: Final[int] = 10 -EMPTY_VOICE: Final[str] = ".." -LINE_NUMBER: Final[str] = "00" -BASS_NUMBER: Final[str] = "01" -PAD_NUMBER: Final[str] = "02" -NOTE_AT_OCTAVE_TWO: Final[str] = "C-2" -NOTE_AT_OCTAVE_THREE: Final[str] = "C-3" -NOTE_AT_OCTAVE_FOUR: Final[str] = "C-4" -HIGHER_OCTAVE: Final[int] = 3 -NO_VOICE_ROW: Final[int] = 2 -SAMPLE_COLUMN: Final[Optional[ChannelName]] = None -SAMPLE_HEADER: Final[str] = "sequencer.tracker.label.column_sample" -SET_VOICE: Final[str] = "sequencer.tracker.label.context_set_voice" -CLEAR_SUBCOLUMN: Final[str] = "sequencer.tracker.label.context_clear_subcolumn" -SUBCOLUMN_LETTERS: Final[Tuple[Tuple[SubColumn, str], ...]] = ( - (SubColumn.VOICE, "n"), - (SubColumn.TRANSPOSE, "t"), - (SubColumn.VOLUME, "v"), -) -EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) -LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) - - -def history_size(screen: Screen) -> int: - return len(screen.sequencer.history.lines()) - - -def on_the_sequencer(screen: Screen) -> None: - screen.tabs.bring_to_front(Tab.SEQUENCER) - screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") - - -def play_a_note(screen: Screen, row: int, channel: ChannelName, key: int) -> None: - screen.sequencer.tracker.click(row, channel, SubColumn.TRANSPOSE) - screen.hand.press_key(key, modifiers=[]) - screen.frames(TYPING_FRAMES) - - -def type_into(screen: Screen, row: int, channel: Optional[ChannelName], subcolumn: SubColumn, text: str) -> None: - screen.sequencer.tracker.click(row, channel, subcolumn) - screen.hand.type_text(text) - screen.frames(TYPING_FRAMES) - - -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - -class TestNotesTypedPianoStyle: - """A note key writes the note at the octave in force into a pitch slot whose channel carries a voice. - - The noise column, a voice slot and a row carrying no voice take no note, and nothing reaches the - history for them. - """ - - def test_notes_land_where_a_voice_plays_and_nowhere_else(self, screen: Screen) -> None: - tracker = screen.sequencer.tracker - history = screen.sequencer.history - - def a_note_lands_at_the_octave_in_force(screen: Screen) -> None: - on_the_sequencer(screen) - assert tracker.octave() == 2 - before = history_size(screen) - - play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) - - assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_TWO - assert tracker.has_caret(PAD_ROW + 1, ChannelName.PULSE2) - assert history_size(screen) == before + 1 - assert history.current().words.endswith(f"t {NOTE_AT_OCTAVE_TWO}") - - def a_new_octave_lands_an_octave_up(screen: Screen) -> None: - tracker.raise_octave() - screen.expect(tracker.octave, HIGHER_OCTAVE.__eq__, description="the octave raised") - - play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) - assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE - - play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C_UP) - assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_FOUR - - def the_noise_column_takes_no_note(screen: Screen) -> None: - type_into(screen, NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE, PAD_NUMBER) - assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE) == PAD_NUMBER - before = history_size(screen) - - play_a_note(screen, NO_VOICE_ROW, ChannelName.NOISE, PIANO_C) - - assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.TRANSPOSE) == NOTE_BLANK - assert history_size(screen) == before - - def the_voice_slot_takes_no_note(screen: Screen) -> None: - before = history_size(screen) - tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) - - screen.hand.press_key(PIANO_C, modifiers=[]) - screen.frames(TYPING_FRAMES) - - assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) == PAD_NUMBER - assert history_size(screen) == before - - def a_row_carrying_no_voice_records_nothing(screen: Screen) -> None: - before = history_size(screen) - - play_a_note(screen, NO_VOICE_ROW, ChannelName.PULSE2, PIANO_C) - - assert tracker.label(NO_VOICE_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_BLANK - assert tracker.has_caret(NO_VOICE_ROW + 1, ChannelName.PULSE2) - assert history_size(screen) == before - - def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: - before = history_size(screen) - - play_a_note(screen, PAD_ROW + 1, ChannelName.PULSE2, PIANO_C) - - assert tracker.label(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE - assert history_size(screen) == before + 1 - - screen.scenario( - a_note_lands_at_the_octave_in_force, - a_new_octave_lands_an_octave_up, - the_noise_column_takes_no_note, - the_voice_slot_takes_no_note, - a_row_carrying_no_voice_records_nothing, - a_note_where_the_voice_plays_on_lands, - leave_letting_the_project_go, - ).run() - - -class TestAVoiceTyped: - """A voice number typed into a slot arrives with its kind's color in the same frame. - - An instrument typed into the Sample column leaves both the number and the color as they were. - """ - - def test_the_color_comes_with_the_number(self, screen: Screen) -> None: - tracker = screen.sequencer.tracker - typed_row = 9 - - def cell_in_frame(row: int, channel: Optional[ChannelName]) -> Tuple[str, Optional[int]]: - return read_label(tracker_cell(row, channel, SubColumn.VOICE)), tracker_cell_theme( - row, channel, SubColumn.VOICE - ) - - def frames_while_typing(row: int, channel: Optional[ChannelName], text: str) -> List[Tuple[str, Optional[int]]]: - tracker.click(row, channel, SubColumn.VOICE) - with screen.record(partial(cell_in_frame, row, channel)) as recording: - screen.hand.type_text(text) - screen.frames(TYPING_FRAMES) - - return recording.values() - - def an_instrument_arrives_in_its_color(screen: Screen) -> None: - on_the_sequencer(screen) - instrument = tracker.theme(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) - - frames = frames_while_typing(typed_row, ChannelName.PULSE1, PAD_NUMBER) - - numbered = [theme for label, theme in frames if label == PAD_NUMBER] - assert numbered - assert all(theme == instrument for theme in numbered) - - def a_sample_arrives_in_its_color(screen: Screen) -> None: - sample = screen.sequencer.tracker.theme(0, ChannelName.PULSE1, SubColumn.VOICE) - - frames = frames_while_typing(typed_row + 2, ChannelName.PULSE1, LINE_NUMBER) - - numbered = [theme for label, theme in frames if label == LINE_NUMBER] - assert numbered - assert all(theme == sample for theme in numbered) - - def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: - standing = tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE), tracker.theme( - typed_row, SAMPLE_COLUMN, SubColumn.VOICE - ) - before = history_size(screen) - - frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) - - assert all(frame[1] == standing[1] for frame in frames) - assert all(frame[0] != PAD_NUMBER for frame in frames) - assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] - assert history_size(screen) == before - - screen.scenario( - an_instrument_arrives_in_its_color, - a_sample_arrives_in_its_color, - an_instrument_in_the_sample_column_changes_nothing, - leave_letting_the_project_go, - ).run() - - -class TestTheSampleColumn: - """The Sample column reads Sample, offers samples alone, and reads a row by the samples its channels play.""" - - def test_its_header_its_menu_and_its_readings(self, screen: Screen) -> None: - tracker = screen.sequencer.tracker - menu = screen.context_menu - cut_row = 6 - instrument_row = 2 - - def the_header_reads_sample(screen: Screen) -> None: - on_the_sequencer(screen) - - assert tracker.header(SAMPLE_COLUMN) == screen.words(SAMPLE_HEADER) - - def set_voice_offers_samples_alone(screen: Screen) -> None: - tracker.right_click(1, SAMPLE_COLUMN, SubColumn.VOICE) - screen.expect(menu.is_shown, bool, description="the cell's menu") - - offered = menu.submenu(screen.words(SET_VOICE)) - - menu.dismiss() - screen.expect(menu.is_shown, operator.not_, description="the menu put away") - assert [enabled for _, enabled in offered] == [True, True, False] - - def a_channel_cell_offers_every_voice(screen: Screen) -> None: - tracker.right_click(1, ChannelName.PULSE2, SubColumn.VOICE) - screen.expect(menu.is_shown, bool, description="the cell's menu") - - offered = menu.submenu(screen.words(SET_VOICE)) - - menu.dismiss() - screen.expect(menu.is_shown, operator.not_, description="the menu put away") - assert len(offered) == len(screen.sequencer.voices.names()) - assert all(enabled for _, enabled in offered) - - def an_instrument_alone_reads_empty(screen: Screen) -> None: - type_into(screen, instrument_row, ChannelName.PULSE1, SubColumn.VOICE, PAD_NUMBER) - - assert tracker.label(instrument_row, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER - assert tracker.label(instrument_row, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE - - def a_row_cut_everywhere_reads_the_cut(screen: Screen) -> None: - for channel in EVERY_CHANNEL: - type_into(screen, cut_row, channel, SubColumn.VOICE, "-") - - assert all(tracker.label(cut_row, channel, SubColumn.VOICE) == NOTE_OFF for channel in EVERY_CHANNEL) - assert tracker.label(cut_row, SAMPLE_COLUMN, SubColumn.VOICE) == NOTE_OFF - - def the_bass_alone_reads_as_itself(screen: Screen) -> None: - assert screen.sequencer.tracker.label(BASS_ROW, SAMPLE_COLUMN, SubColumn.VOICE) == BASS_NUMBER - - screen.scenario( - the_header_reads_sample, - set_voice_offers_samples_alone, - a_channel_cell_offers_every_voice, - an_instrument_alone_reads_empty, - a_row_cut_everywhere_reads_the_cut, - the_bass_alone_reads_as_itself, - leave_letting_the_project_go, - ).run() - - def test_a_volume_lands_where_the_sample_still_plays(self, screen: Screen) -> None: - tracker = screen.sequencer.tracker - placed_row = 1 - - def place_the_line_across_its_channels(screen: Screen) -> None: - on_the_sequencer(screen) - - type_into(screen, placed_row, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) - - assert [tracker.label(placed_row, channel, SubColumn.VOICE) for channel in LINE_CHANNELS] == [ - LINE_NUMBER - ] * 3 - assert tracker.label(placed_row, SAMPLE_COLUMN, SubColumn.VOICE) == LINE_NUMBER - - def a_volume_reaches_every_channel_it_plays(screen: Screen) -> None: - type_into(screen, placed_row + 1, SAMPLE_COLUMN, SubColumn.VOLUME, "8") - - assert {channel: tracker.label(placed_row + 1, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { - ChannelName.PULSE1: "8", - ChannelName.PULSE2: BLANK, - ChannelName.TRIANGLE: "8", - ChannelName.NOISE: "8", - } - - def a_channel_cut_stops_taking_it(screen: Screen) -> None: - type_into(screen, placed_row + 2, ChannelName.NOISE, SubColumn.VOICE, "-") - - type_into(screen, placed_row + 3, SAMPLE_COLUMN, SubColumn.VOLUME, "9") - - assert {channel: tracker.label(placed_row + 3, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { - ChannelName.PULSE1: "9", - ChannelName.PULSE2: BLANK, - ChannelName.TRIANGLE: "9", - ChannelName.NOISE: BLANK, - } - - def a_channel_given_an_instrument_stops_taking_it(screen: Screen) -> None: - type_into(screen, placed_row + 4, ChannelName.TRIANGLE, SubColumn.VOICE, PAD_NUMBER) - - type_into(screen, placed_row + 5, SAMPLE_COLUMN, SubColumn.VOLUME, "A") - - assert {channel: tracker.label(placed_row + 5, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { - ChannelName.PULSE1: "A", - ChannelName.PULSE2: BLANK, - ChannelName.TRIANGLE: BLANK, - ChannelName.NOISE: BLANK, - } - - screen.scenario( - place_the_line_across_its_channels, - a_volume_reaches_every_channel_it_plays, - a_channel_cut_stops_taking_it, - a_channel_given_an_instrument_stops_taking_it, - leave_letting_the_project_go, - ).run() - - -class TestABlockPastedOnTheSampleColumn: - """A block holding an instrument pasted onto the Sample column lands everything but the instrument's cells.""" - - def test_the_instrument_is_passed_over(self, screen: Screen) -> None: - tracker = screen.sequencer.tracker - anchor = 9 - - def copy_an_instrument_and_a_sample(screen: Screen) -> None: - on_the_sequencer(screen) - type_into(screen, PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE, LINE_NUMBER) - tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) - tracker.shift_click(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE) - - screen.press_shortcut(ShortcutId.TRACKER_COPY_BLOCK) - - def paste_it_on_the_sample_column(screen: Screen) -> None: - tracker.click(anchor, SAMPLE_COLUMN, SubColumn.VOICE) - - screen.press_shortcut(ShortcutId.TRACKER_PASTE_BLOCK) - - screen.expect( - partial(tracker.label, anchor + 1, SAMPLE_COLUMN, SubColumn.VOICE), - LINE_NUMBER.__eq__, - description="the sample landed", - ) - assert tracker.label(anchor, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE - assert all(tracker.label(anchor, channel, SubColumn.VOICE) != PAD_NUMBER for channel in EVERY_CHANNEL) - - screen.scenario( - copy_an_instrument_and_a_sample, paste_it_on_the_sample_column, leave_letting_the_project_go - ).run() - - -class TestHistoryLinesNameTheSlot: - """A history line clearing a slot names it n for the voice, t for the transpose and v for the volume.""" - - def test_each_slot_has_its_letter(self, screen: Screen) -> None: - tracker = screen.sequencer.tracker - history = screen.sequencer.history - menu = screen.context_menu - letters: List[str] = [] - - def clear_each_slot_of_the_pad(screen: Screen) -> None: - on_the_sequencer(screen) - play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) - type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.VOLUME, "9") - - for subcolumn, _ in reversed(SUBCOLUMN_LETTERS): - tracker.right_click(PAD_ROW, ChannelName.PULSE2, subcolumn) - screen.expect(menu.is_shown, bool, description="the cell's menu") - menu.choose(screen.words(CLEAR_SUBCOLUMN)) - screen.expect(menu.is_shown, operator.not_, description="the menu answered") - screen.frames(TYPING_FRAMES) - letters.append(history.current().words) - - for line, (_, letter) in zip(letters, reversed(SUBCOLUMN_LETTERS)): - assert letter in line.split() - - assert len(set(letters)) == len(SUBCOLUMN_LETTERS) - - screen.scenario(clear_each_slot_of_the_pad, leave_letting_the_project_go).run() - - -class TestPlayingFromATrackerRow: - """Playing the song from the caret's row reads as playing in the Playback menu, Stop answering.""" - - @pytest.mark.xfail( - strict=True, - raises=AssertionError, - reason="bugs-and-todos ยง Bugs: playing from a tracker row leaves the Playback menu reading Play", - ) - def test_the_menu_follows(self, screen: Screen) -> None: - on_the_sequencer(screen) - screen.sequencer.tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) - - screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) - - screen.expect(screen.sequencer.playback.can_stop, bool, description="Stop answering") - assert screen.sequencer.playback.play_entry() == screen.words(PAUSE) diff --git a/tests/screens/sequencer/tracker/__init__.py b/tests/screens/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/tracker/constants.py b/tests/screens/sequencer/tracker/constants.py new file mode 100644 index 000000000..e91590f8a --- /dev/null +++ b/tests/screens/sequencer/tracker/constants.py @@ -0,0 +1,10 @@ +from typing import Final, Optional + +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A + +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") +TYPING_FRAMES: Final[int] = 10 +LINE_NUMBER: Final[str] = "00" +PAD_NUMBER: Final[str] = "02" +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None diff --git a/tests/screens/sequencer/tracker/steps.py b/tests/screens/sequencer/tracker/steps.py new file mode 100644 index 000000000..dcf1ac6da --- /dev/null +++ b/tests/screens/sequencer/tracker/steps.py @@ -0,0 +1,22 @@ +from typing import Optional + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.constants import TYPING_FRAMES +from tests.suite.screens.screen import Screen + + +def play_a_note(screen: Screen, row: int, channel: ChannelName, key: int) -> None: + """Clicks the transpose slot of a channel at a row, presses a piano key and lets the typing settle.""" + screen.sequencer.tracker.click(row, channel, SubColumn.TRANSPOSE) + screen.hand.press_key(key, modifiers=[]) + screen.frames(TYPING_FRAMES) + + +def type_into(screen: Screen, row: int, channel: Optional[ChannelName], subcolumn: SubColumn, text: str) -> None: + """Clicks a slot of a channel (or of the Sample column, when the channel is None), types the text + and lets the typing settle. + """ + screen.sequencer.tracker.click(row, channel, subcolumn) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py new file mode 100644 index 000000000..443030b16 --- /dev/null +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -0,0 +1,193 @@ +from functools import partial +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_BLANK +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PAD_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.keys import IMGUI_LETTER_A +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from tests.suite.screens.views.tracker import tracker_cell, tracker_cell_theme +from tests.suite.screens.vocabulary.playback import PAUSE +from tests.suite.screens.worlds.songs import PAD_ROW + +PIANO_C_UP: Final[int] = IMGUI_LETTER_A + ord("q") - ord("a") +NOTE_AT_OCTAVE_TWO: Final[str] = "C-2" +NOTE_AT_OCTAVE_THREE: Final[str] = "C-3" +NOTE_AT_OCTAVE_FOUR: Final[str] = "C-4" +HIGHER_OCTAVE: Final[int] = 3 +NO_VOICE_ROW: Final[int] = 2 + + +def history_size(screen: Screen) -> int: + """The number of lines in the Sequencer's history.""" + return len(screen.sequencer.history.lines()) + + +class TestNotesTypedPianoStyle: + """A note key writes the note at the octave in force into a pitch slot whose channel carries a voice. + + The noise column, a voice slot and a row whose channel carries no voice stay blank, and the history + stays as it was for them. + """ + + def test_notes_land_where_a_voice_plays_and_nowhere_else(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + history = screen.sequencer.history + + def a_note_lands_at_the_octave_in_force(screen: Screen) -> None: + on_the_sequencer(screen) + assert tracker.octave() == 2 + before = history_size(screen) + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_TWO + assert tracker.has_caret(PAD_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + 1 + assert history.current().words.endswith(f"t {NOTE_AT_OCTAVE_TWO}") + + def a_new_octave_lands_an_octave_up(screen: Screen) -> None: + tracker.raise_octave() + screen.expect(tracker.octave, HIGHER_OCTAVE.__eq__, description="the octave raised") + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C_UP) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_FOUR + + def the_noise_column_takes_no_note(screen: Screen) -> None: + type_into(screen, NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE, PAD_NUMBER) + assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.VOICE) == PAD_NUMBER + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.NOISE, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.NOISE, SubColumn.TRANSPOSE) == NOTE_BLANK + assert history_size(screen) == before + + def the_voice_slot_takes_no_note(screen: Screen) -> None: + before = history_size(screen) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) == PAD_NUMBER + assert history_size(screen) == before + + def a_row_carrying_no_voice_records_nothing(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_BLANK + assert tracker.has_caret(NO_VOICE_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + + def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, PAD_ROW + 1, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + assert history_size(screen) == before + 1 + + screen.scenario( + a_note_lands_at_the_octave_in_force, + a_new_octave_lands_an_octave_up, + the_noise_column_takes_no_note, + the_voice_slot_takes_no_note, + a_row_carrying_no_voice_records_nothing, + a_note_where_the_voice_plays_on_lands, + leave_letting_the_project_go, + ).run() + + +class TestAVoiceTyped: + """A voice number typed into a slot arrives with its kind's color in the same frame. + + An instrument typed into the Sample column keeps the number and the color as they were. + """ + + def test_the_color_comes_with_the_number(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + typed_row = 9 + + def cell_in_frame(row: int, channel: Optional[ChannelName]) -> Tuple[str, Optional[int]]: + return read_label(tracker_cell(row, channel, SubColumn.VOICE)), tracker_cell_theme( + row, channel, SubColumn.VOICE + ) + + def frames_while_typing(row: int, channel: Optional[ChannelName], text: str) -> List[Tuple[str, Optional[int]]]: + tracker.click(row, channel, SubColumn.VOICE) + with screen.record(partial(cell_in_frame, row, channel)) as recording: + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + return recording.values() + + def an_instrument_arrives_in_its_color(screen: Screen) -> None: + on_the_sequencer(screen) + instrument = tracker.theme(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + frames = frames_while_typing(typed_row, ChannelName.PULSE1, PAD_NUMBER) + + numbered = [theme for label, theme in frames if label == PAD_NUMBER] + assert numbered + assert all(theme == instrument for theme in numbered) + + def a_sample_arrives_in_its_color(screen: Screen) -> None: + sample = screen.sequencer.tracker.theme(0, ChannelName.PULSE1, SubColumn.VOICE) + + frames = frames_while_typing(typed_row + 2, ChannelName.PULSE1, LINE_NUMBER) + + numbered = [theme for label, theme in frames if label == LINE_NUMBER] + assert numbered + assert all(theme == sample for theme in numbered) + + def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: + standing = tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE), tracker.theme( + typed_row, SAMPLE_COLUMN, SubColumn.VOICE + ) + before = history_size(screen) + + frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) + + assert all(frame[1] == standing[1] for frame in frames) + assert all(frame[0] != PAD_NUMBER for frame in frames) + assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] + assert history_size(screen) == before + + screen.scenario( + an_instrument_arrives_in_its_color, + a_sample_arrives_in_its_color, + an_instrument_in_the_sample_column_changes_nothing, + leave_letting_the_project_go, + ).run() + + +class TestPlayingFromATrackerRow: + """Playing the song from the caret's row reads as playing in the Playback menu, Stop answering.""" + + @pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="bugs-and-todos ยง Bugs: playing from a tracker row leaves the Playback menu reading Play", + ) + def test_the_menu_follows(self, screen: Screen) -> None: + """After the play-from-row shortcut, the Playback menu offers Pause and Stop answers.""" + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) + + screen.expect(screen.sequencer.playback.can_stop, bool, description="Stop answering") + assert screen.sequencer.playback.play_entry() == screen.words(PAUSE) diff --git a/tests/screens/sequencer/tracker/test_sample_column.py b/tests/screens/sequencer/tracker/test_sample_column.py new file mode 100644 index 000000000..c4839af7f --- /dev/null +++ b/tests/screens/sequencer/tracker/test_sample_column.py @@ -0,0 +1,220 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK, NOTE_OFF +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PAD_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from tests.suite.screens.worlds.songs import BASS_ROW, PAD_ROW + +EMPTY_VOICE: Final[str] = ".." +BASS_NUMBER: Final[str] = "01" +SAMPLE_HEADER: Final[str] = "sequencer.tracker.label.column_sample" +SET_VOICE: Final[str] = "sequencer.tracker.label.context_set_voice" +CLEAR_SUBCOLUMN: Final[str] = "sequencer.tracker.label.context_clear_subcolumn" + +SUBCOLUMN_LETTERS: Final[Tuple[Tuple[SubColumn, str], ...]] = ( + (SubColumn.VOICE, "n"), + (SubColumn.TRANSPOSE, "t"), + (SubColumn.VOLUME, "v"), +) + +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) + + +class TestTheSampleColumn: + """The Sample column reads Sample, offers only samples, and reads a row by the samples its channels play.""" + + def test_its_header_its_menu_and_its_readings(self, screen: Screen) -> None: + """The header, the Set voice menu and the readings of an instrument, a cut and a bass row follow + the column's rule. + """ + tracker = screen.sequencer.tracker + menu = screen.context_menu + cut_row = 6 + instrument_row = 2 + + def the_header_reads_sample(screen: Screen) -> None: + on_the_sequencer(screen) + + assert tracker.header(SAMPLE_COLUMN) == screen.words(SAMPLE_HEADER) + + def set_voice_offers_samples_alone(screen: Screen) -> None: + tracker.right_click(1, SAMPLE_COLUMN, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert [enabled for _, enabled in offered] == [True, True, False] + + def a_channel_cell_offers_every_voice(screen: Screen) -> None: + tracker.right_click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert len(offered) == len(screen.sequencer.voices.names()) + assert all(enabled for _, enabled in offered) + + def an_instrument_alone_reads_empty(screen: Screen) -> None: + type_into(screen, instrument_row, ChannelName.PULSE1, SubColumn.VOICE, PAD_NUMBER) + + assert tracker.label(instrument_row, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER + assert tracker.label(instrument_row, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + + def a_row_cut_everywhere_reads_the_cut(screen: Screen) -> None: + for channel in EVERY_CHANNEL: + type_into(screen, cut_row, channel, SubColumn.VOICE, "-") + + assert all(tracker.label(cut_row, channel, SubColumn.VOICE) == NOTE_OFF for channel in EVERY_CHANNEL) + assert tracker.label(cut_row, SAMPLE_COLUMN, SubColumn.VOICE) == NOTE_OFF + + def the_bass_alone_reads_as_itself(screen: Screen) -> None: + assert screen.sequencer.tracker.label(BASS_ROW, SAMPLE_COLUMN, SubColumn.VOICE) == BASS_NUMBER + + screen.scenario( + the_header_reads_sample, + set_voice_offers_samples_alone, + a_channel_cell_offers_every_voice, + an_instrument_alone_reads_empty, + a_row_cut_everywhere_reads_the_cut, + the_bass_alone_reads_as_itself, + leave_letting_the_project_go, + ).run() + + def test_a_volume_lands_where_the_sample_still_plays(self, screen: Screen) -> None: + """A volume typed into the Sample column reaches the channels that still play the sample. + + A channel cut or given an instrument on its own is left out. + """ + tracker = screen.sequencer.tracker + placed_row = 1 + + def place_the_line_across_its_channels(screen: Screen) -> None: + on_the_sequencer(screen) + + type_into(screen, placed_row, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) + + assert [tracker.label(placed_row, channel, SubColumn.VOICE) for channel in LINE_CHANNELS] == [ + LINE_NUMBER + ] * 3 + assert tracker.label(placed_row, SAMPLE_COLUMN, SubColumn.VOICE) == LINE_NUMBER + + def a_volume_reaches_every_channel_it_plays(screen: Screen) -> None: + type_into(screen, placed_row + 1, SAMPLE_COLUMN, SubColumn.VOLUME, "8") + + assert {channel: tracker.label(placed_row + 1, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "8", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "8", + ChannelName.NOISE: "8", + } + + def a_channel_cut_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 2, ChannelName.NOISE, SubColumn.VOICE, "-") + + type_into(screen, placed_row + 3, SAMPLE_COLUMN, SubColumn.VOLUME, "9") + + assert {channel: tracker.label(placed_row + 3, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "9", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "9", + ChannelName.NOISE: BLANK, + } + + def a_channel_given_an_instrument_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 4, ChannelName.TRIANGLE, SubColumn.VOICE, PAD_NUMBER) + + type_into(screen, placed_row + 5, SAMPLE_COLUMN, SubColumn.VOLUME, "A") + + assert {channel: tracker.label(placed_row + 5, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "A", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: BLANK, + ChannelName.NOISE: BLANK, + } + + screen.scenario( + place_the_line_across_its_channels, + a_volume_reaches_every_channel_it_plays, + a_channel_cut_stops_taking_it, + a_channel_given_an_instrument_stops_taking_it, + leave_letting_the_project_go, + ).run() + + +class TestABlockPastedOnTheSampleColumn: + """A block holding an instrument pasted onto the Sample column lands its samples and leaves the instrument's cells empty.""" + + def test_the_instrument_is_passed_over(self, screen: Screen) -> None: + """The pasted sample appears below the anchor, and no cell takes the instrument's number.""" + tracker = screen.sequencer.tracker + anchor = 9 + + def copy_an_instrument_and_a_sample(screen: Screen) -> None: + on_the_sequencer(screen) + type_into(screen, PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE, LINE_NUMBER) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + tracker.shift_click(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_COPY_BLOCK) + + def paste_it_on_the_sample_column(screen: Screen) -> None: + tracker.click(anchor, SAMPLE_COLUMN, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PASTE_BLOCK) + + screen.expect( + partial(tracker.label, anchor + 1, SAMPLE_COLUMN, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the sample landed", + ) + assert tracker.label(anchor, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + assert all(tracker.label(anchor, channel, SubColumn.VOICE) != PAD_NUMBER for channel in EVERY_CHANNEL) + + screen.scenario( + copy_an_instrument_and_a_sample, paste_it_on_the_sample_column, leave_letting_the_project_go + ).run() + + +class TestHistoryLinesNameTheSlot: + """A history line clearing a slot names it n for the voice, t for the transpose and v for the volume.""" + + def test_each_slot_has_its_letter(self, screen: Screen) -> None: + """Clearing the volume, the transpose and the voice of one cell in turn gives three history lines, + each with its own letter. + """ + tracker = screen.sequencer.tracker + history = screen.sequencer.history + menu = screen.context_menu + letters: List[str] = [] + + def clear_each_slot_of_the_pad(screen: Screen) -> None: + on_the_sequencer(screen) + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.VOLUME, "9") + + for subcolumn, _ in reversed(SUBCOLUMN_LETTERS): + tracker.right_click(PAD_ROW, ChannelName.PULSE2, subcolumn) + screen.expect(menu.is_shown, bool, description="the cell's menu") + menu.choose(screen.words(CLEAR_SUBCOLUMN)) + screen.expect(menu.is_shown, operator.not_, description="the menu answered") + screen.frames(TYPING_FRAMES) + letters.append(history.current().words) + + for line, (_, letter) in zip(letters, reversed(SUBCOLUMN_LETTERS)): + assert letter in line.split() + + assert len(set(letters)) == len(SUBCOLUMN_LETTERS) + + screen.scenario(clear_each_slot_of_the_pad, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/voices/__init__.py b/tests/screens/sequencer/voices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/test_voices.py b/tests/screens/sequencer/voices/test_voices_card.py similarity index 88% rename from tests/screens/sequencer/test_voices.py rename to tests/screens/sequencer/voices/test_voices_card.py index c065551aa..9162f5f90 100644 --- a/tests/screens/sequencer/test_voices.py +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -12,30 +12,34 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.application import Startup from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.items import Item, enclosing_regions, read_region_view +from tests.suite.screens.dearpygui.items.regions import enclosing_regions, read_region_view +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.keyboard import press_combination from tests.suite.screens.screen import Screen from tests.suite.screens.steps.reconstructions import BY_CONFIGURATION from tests.suite.screens.steps.sequencer import forgive_the_hover_race, leave_letting_the_project_go from tests.suite.screens.views.menus import MenuEntry from tests.suite.screens.vocabulary.playback import PAUSE -from tests.suite.screens.world import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD, SHORT_RECONSTRUCTION +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD POOL_ACTIONS: Final[Tuple[str, ...]] = ( "sequencer.voices.label.new_instrument", "sequencer.voices.label.add_sample", "sequencer.voices.label.import_instrument", ) + PLAY_VOICE: Final[str] = "global.context.label.play" STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample" STATUS_SEPARATOR: Final[str] = "sequencer.voices.template.status_channel_separator" + LINE_CHANNEL_NAMES: Final[Tuple[str, ...]] = ( "global.context.label.pulse_1", "global.context.label.triangle", "global.context.label.noise", ) + EMPTY_SPACE_INSET: Final[int] = 10 SETTLING_FRAMES: Final[int] = 20 MOVE_DOWN_KEYS: Final[str] = "Alt+Down" @@ -43,18 +47,14 @@ ADDED_NUMBER: Final[str] = "03" -@pytest.fixture -def startup() -> Startup: - return Startup(reconstruction=None, project=ARRANGED_PROJECT) - - def voice_row(screen: Screen, name: str) -> Item: + """Brings the Sequencer tab to the front and returns the list row of the voice called ``name``.""" screen.tabs.bring_to_front(Tab.SEQUENCER) return screen.expect_item(partial(screen.sequencer.voices.row, name), description=f"the row of {name}") def right_click_the_empty_list(screen: Screen) -> None: - """Presses the right button over the list below its rows.""" + """Presses the right button over the list, below its rows.""" view = screen.bridge.ask(lambda: read_region_view(enclosing_regions(TAG_SEQUENCER_VOICES_TABLE)[0])) assert view is not None screen.hand.right_click_at( @@ -78,13 +78,14 @@ def menu_on_the_bar(screen: Screen, group: MenuElements) -> List[MenuEntry]: class TestTheVoicesMenus: - """A voice row's menu offers the voice's actions and the pool's; the empty list offers the pool's alone. + """A voice row's menu offers the voice's actions and the pool's; the empty list offers only the pool's. The Voice and Edit menus name the voice's actions the row's menu names, Play aside, and the keys they print are the keys that fire them. """ def test_menus_agree_and_their_keys_fire(self, screen: Screen) -> None: + """The row's menu, the empty list's menu and the two bar menus agree, and the printed Move down keys move the voice.""" voices = screen.sequencer.voices menu = screen.context_menu pool = tuple(screen.words(key) for key in POOL_ACTIONS) @@ -145,6 +146,7 @@ class TestHoveringAVoice: """The status line says what the voice under the pointer is: its name, its kind and the channels it plays.""" def test_the_status_names_the_sample(self, screen: Screen) -> None: + """Hovering a sample's row fills the status line with its name and its channels.""" row = voice_row(screen, LINE) separator = screen.words(STATUS_SEPARATOR) channels = separator.join(screen.words(key) for key in LINE_CHANNEL_NAMES) @@ -159,9 +161,13 @@ def test_the_status_names_the_sample(self, screen: Screen) -> None: class TestTheKeysOfACollapsedVoicesCard: - """With the Voices card collapsed, the keys of the voice picked change nothing; open, they act.""" + """With the Voices card collapsed, the keys of the voice picked keep the voices as they were; open, they act. + + The line is picked and the card collapsed; Move down and Remove leave the list and the windows as they were. Opening the card makes Move down move the line. + """ def test_keys_wait_for_the_card(self, screen: Screen) -> None: + """The keys act once the card is open again.""" voices = screen.sequencer.voices def pick_the_line_and_collapse_the_card(screen: Screen) -> None: @@ -198,7 +204,7 @@ def open_they_act(screen: Screen) -> None: class TestNewInstrumentWithNoProjectOpen: - """New instrument with no project open adds nothing, and leaving asks nothing.""" + """New instrument with no project open keeps the voice list empty, and leaving goes through at once.""" @pytest.mark.xfail( strict=True, @@ -206,6 +212,7 @@ class TestNewInstrumentWithNoProjectOpen: reason="bugs-and-todos ยง Bugs: New instrument with no project open writes into a project nobody opened", ) def test_nothing_is_added(self, screen: Screen) -> None: + """The voice list stays empty and the exit shortcut ends the application.""" voices = screen.sequencer.voices screen.tabs.bring_to_front(Tab.SEQUENCER) screen.project.close() @@ -223,6 +230,7 @@ class TestASampleAddedFromTheBrowser: """A reconstruction double-clicked in the Sequencer's browser joins the voices; placed, the song plays it.""" def test_it_joins_and_plays(self, screen: Screen) -> None: + """The voices list a fourth voice after the double click, and playing the song shows Pause once the sample is placed.""" voices = screen.sequencer.voices browser = screen.sequencer.browser tracker = screen.sequencer.tracker diff --git a/tests/suite/screens/application/__init__.py b/tests/suite/screens/application/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/application/boundaries.py b/tests/suite/screens/application/boundaries.py new file mode 100644 index 000000000..9b2dd0882 --- /dev/null +++ b/tests/suite/screens/application/boundaries.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import TableHighlights +from tests.suite.screens.boundaries.reveals import FileManagerStandIn +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.holds.base import Holds + + +@dataclass(frozen=True) +class Boundaries: + """What stands between a scenario's application and the desktop around it.""" + + dialogs: ScriptedFileDialogs + errors: ErrorRecords + spawns: SpawnGuard + holds: Holds + file_manager: FileManagerStandIn + highlights: TableHighlights diff --git a/tests/suite/screens/application.py b/tests/suite/screens/application/running.py similarity index 79% rename from tests/suite/screens/application.py rename to tests/suite/screens/application/running.py index 943a25444..1f819d0ef 100644 --- a/tests/suite/screens/application.py +++ b/tests/suite/screens/application/running.py @@ -1,7 +1,6 @@ import threading -from dataclasses import dataclass from pathlib import Path -from typing import Final, List, Optional +from typing import Final, List import dearpygui.dearpygui as dpg @@ -10,11 +9,7 @@ from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager -from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs -from tests.suite.screens.boundaries.errors import ErrorRecords -from tests.suite.screens.boundaries.highlights import TableHighlights -from tests.suite.screens.boundaries.reveals import FileManagerStandIn -from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.application.boundaries import Boundaries from tests.suite.screens.checks import ( AfterCheckError, contained_findings, @@ -26,44 +21,12 @@ from tests.suite.screens.dearpygui.bridge import Bridge, RenderThreadStoppedError from tests.suite.screens.dearpygui.hosting import SCENARIO_THREAD_NAME from tests.suite.screens.dearpygui.screenshot import capture -from tests.suite.screens.holds import Holds from tests.suite.screens.keyboard import primary_combination from tests.suite.screens.render_thread import QueueRenderThread EXIT_TIMEOUT_SECONDS: Final[float] = 15.0 -@dataclass(frozen=True) -class Startup: - """The documents a scenario's application opens as it starts, as ``sampletones open`` hands them over. - - Attributes: - reconstruction: The reconstruction open on the Reconstructions tab, if any. - project: The project open in the Sequencer, if any. - """ - - reconstruction: Optional[Path] - project: Optional[Path] - - -NOTHING_TO_OPEN: Final[Startup] = Startup( - reconstruction=None, - project=None, -) - - -@dataclass(frozen=True) -class Boundaries: - """What stands between a scenario's application and the desktop around it.""" - - dialogs: ScriptedFileDialogs - errors: ErrorRecords - spawns: SpawnGuard - holds: Holds - file_manager: FileManagerStandIn - highlights: TableHighlights - - class ScreenApplication: """SampleToNES running for one scenario: its loop on the main thread, its ending read by the after-checks. @@ -93,13 +56,20 @@ def __init__( @property def language(self) -> LanguageManager: + """The application's language manager, which turns a language key into the words a user reads.""" return self._application.language_manager @property def shortcuts(self) -> ShortcutManager: + """The application's shortcut manager, which says what keys each action has under the scheme in + place. + """ return self._application.shortcut_manager def run(self) -> None: + """Starts the render thread and runs the application's loop on the calling thread until the loop + ends. + """ self._boundaries.spawns.arm() self._render_thread.start() try: @@ -108,6 +78,14 @@ def run(self) -> None: self._render_thread.stop() def finish(self, *, failed: bool) -> None: + """Ends the scenario: lets go of its holds, reads the after-checks and stops the application. + + A scenario that ended well leaves through the Exit shortcut; a failed one, or a screen the + after-checks object to, stops the loop outright after a screenshot is kept. + + Raises: + AfterCheckError: If the application broke a promise every scenario holds it to. + """ self._boundaries.holds.release_all() findings = self._boundary_findings() if self._render_thread.is_running(): @@ -124,6 +102,14 @@ def finish(self, *, failed: bool) -> None: raise AfterCheckError("\n".join(findings)) def close(self) -> None: + """Reads the checks that apply once the application has stopped. + + They cover late errors, surviving threads and the session state the application writes. + + Raises: + AfterCheckError: If an error came late, a thread outlived the exit or the session state is + missing. + """ findings = quiet_findings(self._boundaries.errors, self._reported_errors, None) findings.extend(surviving_thread_findings(_application_threads())) if not self._state_path.exists(): @@ -149,12 +135,14 @@ def _screen_findings(self) -> List[str]: return screen_findings(state) def _exit(self) -> bool: + """Presses the Exit shortcut and says whether the application stopped within the exit's time.""" combination = primary_combination(self._application.shortcut_manager.shortcut(ShortcutId.EXIT)) event = KeyEvent(key=combination.key, modifiers=combination.modifiers) self._bridge.ask(lambda: self._application.key_router.route(event)) return self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) def _halt(self) -> None: + """Stops the loop outright and waits for it to end.""" try: self._bridge.ask(dpg.stop_dearpygui) except RenderThreadStoppedError: diff --git a/tests/suite/screens/application/startup.py b/tests/suite/screens/application/startup.py new file mode 100644 index 000000000..fe2f0363f --- /dev/null +++ b/tests/suite/screens/application/startup.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class Startup: + """The documents a scenario's application opens as it starts, as ``sampletones open`` hands them over. + + Attributes: + reconstruction: The reconstruction open on the Reconstructions tab, if any. + project: The project open in the Sequencer, if any. + """ + + reconstruction: Optional[Path] + project: Optional[Path] diff --git a/tests/suite/screens/boundaries/dialogs.py b/tests/suite/screens/boundaries/dialogs.py index 051861ce2..f10f4ad3c 100644 --- a/tests/suite/screens/boundaries/dialogs.py +++ b/tests/suite/screens/boundaries/dialogs.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from typing import Deque, Dict, Final, List, Optional, Tuple +from typing import Deque, Dict, List, Optional, Tuple import pytest @@ -13,21 +13,21 @@ class DialogKind(StrEnum): + """What a file dialog asks the user for: a file to open, a path to save to or a folder.""" + OPEN = "open" SAVE = "save" DIRECTORY = "directory" -UNTYPED: Final[None] = None - - @dataclass(frozen=True) class DialogAnswer: - """What a scenario has a dialog answer: a path, and for a save dialog the file type picked in its selector. + """What a scenario queued for a dialog to answer: a path, and for a save dialog the type picked in its + selector. Attributes: path: The path the dialog answers with. - type_name: The name of the offered file type the reader picks, or ``None`` to leave the selector alone. + type_name: The name of the offered file type to pick, or ``None`` to leave the selector as it was. """ path: Path @@ -81,27 +81,29 @@ def answer( ) -> None: """Queues the answer the next dialog of ``kind`` gives: ``path``, or ``None`` to dismiss it.""" with self._lock: - self._answers[kind].append(None if path is None else DialogAnswer(path=path, type_name=UNTYPED)) + self._answers[kind].append(None if path is None else DialogAnswer(path=path, type_name=None)) def answer_save_as( self, path: Path, type_name: str, ) -> None: - """Queues the answer the next save dialog gives: ``path``, with the offered type named ``type_name`` picked. + """Queues the answer the next save dialog gives: ``path``, with the type named ``type_name`` picked. - A dialog offering no type of that name is left unanswered, which the after-checks report. + A dialog offering a type of another name stays unanswered, which the after-checks report. """ with self._lock: self._answers[DialogKind.SAVE].append(DialogAnswer(path=path, type_name=type_name)) @property def requests(self) -> Tuple[DialogRequest, ...]: + """Every dialog the application opened so far, in order, answered or not.""" with self._lock: return tuple(self._requests) @property def unanswered(self) -> Tuple[DialogRequest, ...]: + """The dialogs that opened while no answer was queued for them.""" return tuple(request for request in self.requests if not request.answered) def open_file( @@ -111,6 +113,7 @@ def open_file( initial_directory: Optional[Path], filters: Tuple[FileFilter, ...], ) -> Optional[Path]: + """Answers an open dialog with the queued path, or ``None`` when it is dismissed.""" return _path( self._take( DialogKind.OPEN, @@ -129,6 +132,9 @@ def save_file( suggested_name: Optional[str], filters: Tuple[FileFilter, ...], ) -> Optional[SaveDestination]: + """Answers a save dialog with the queued path and the file type picked, or ``None`` when it is + dismissed. + """ answer = self._take( DialogKind.SAVE, title=title, @@ -147,6 +153,7 @@ def select_directory( title: str, initial_directory: Optional[Path], ) -> Optional[Path]: + """Answers a folder dialog with the queued path, or ``None`` when it is dismissed.""" return _path( self._take( DialogKind.DIRECTORY, @@ -166,6 +173,7 @@ def _take( suggested_name: Optional[str], filters: Tuple[FileFilter, ...], ) -> Optional[DialogAnswer]: + """Takes the answer queued for ``kind``, notes the request and returns the answer, if any.""" with self._lock: queued = self._answers[kind] answered = bool(queued) diff --git a/tests/suite/screens/boundaries/errors.py b/tests/suite/screens/boundaries/errors.py index 436d3cfcc..71061af77 100644 --- a/tests/suite/screens/boundaries/errors.py +++ b/tests/suite/screens/boundaries/errors.py @@ -35,6 +35,7 @@ def __init__(self) -> None: self._lock = threading.Lock() def install(self) -> None: + """Starts recording what the application logs and what its threads let escape.""" logging.getLogger(SAMPLETONES_NAME).addHandler(self) threading.excepthook = self._escaped @@ -54,7 +55,9 @@ def unclaimed( return tuple(record.message for record in self._records[start:stop] if not record.claimed) def claim(self, naming: str) -> bool: - """Takes the first unclaimed error whose message holds ``naming`` as provoked, and says whether one did.""" + """Takes the first unclaimed error whose message holds ``naming`` as provoked, and says whether one + did. + """ with self._lock: for record in self._records: if not record.claimed and naming in record.message: @@ -64,6 +67,7 @@ def claim(self, naming: str) -> bool: return False def emit(self, record: logging.LogRecord) -> None: + """Records a logged error, and a warning logged from within the exit's join on background work.""" if record.levelno < logging.ERROR and not _logged_by_the_exit_join(): return diff --git a/tests/suite/screens/boundaries/highlights.py b/tests/suite/screens/boundaries/highlights.py index 0d6c88331..2ed2be267 100644 --- a/tests/suite/screens/boundaries/highlights.py +++ b/tests/suite/screens/boundaries/highlights.py @@ -8,11 +8,12 @@ TableItem = Union[int, str] Color = Tuple[float, ...] - WHOLE_LINE: Final[None] = None class HighlightKind(StrEnum): + """The stretch of a table one highlight covers.""" + ROW = "row" CELL = "cell" COLUMN = "column" @@ -49,6 +50,7 @@ def __init__(self) -> None: self._lock = threading.Lock() def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Wraps DearPyGui's calls that lay and lift table highlights, so each one is noted as it runs.""" lay_row = dpg.highlight_table_row lay_cell = dpg.highlight_table_cell lay_column = dpg.highlight_table_column diff --git a/tests/suite/screens/boundaries/spawns.py b/tests/suite/screens/boundaries/spawns.py index 6eff218f1..8156b6cfa 100644 --- a/tests/suite/screens/boundaries/spawns.py +++ b/tests/suite/screens/boundaries/spawns.py @@ -7,6 +7,7 @@ LIBRARY_PROBES: Final[FrozenSet[str]] = frozenset({"cc", "gcc", "ld", "ldconfig", "objdump"}) PROGRAM_ARGUMENT: Final[int] = 0 ARGUMENTS_ARGUMENT: Final[int] = 1 + SPAWN_EVENTS: Final[FrozenSet[str]] = frozenset( { "os.exec", @@ -29,11 +30,10 @@ class SpawnGuard: A file manager, a browser or a dialog tool would open on the desktop around the scenario's display. The guard is an audit hook, so it stands in front of every way Python starts a program, however - a module imported it. The application's own worker processes start through multiprocessing, - which the hook leaves alone, and so are the tools ``ctypes.util.find_library`` asks where a - shared library lies, and the stand-in programs of the folder the guard is told to allow. A - refused try is remembered by its program and arguments alone, since the environment it carried - can hold the secrets of the machine the run is on. + a module imported it. It lets through the application's own worker processes, which start through + multiprocessing, the tools ``ctypes.util.find_library`` asks where a shared library lies, and the + stand-in programs of the folder it is told to allow. A refused try is remembered by its program and + arguments alone, since the environment it carried can hold the secrets of the machine the run is on. """ def __init__(self) -> None: @@ -43,7 +43,7 @@ def __init__(self) -> None: self._lock = threading.Lock() def install(self) -> None: - """Puts the guard in front of the process for good; it refuses nothing until armed.""" + """Puts the guard in front of the process for good; it lets every try through until armed.""" sys.addaudithook(self._audit) def allow(self, folder: Path) -> None: @@ -51,10 +51,12 @@ def allow(self, folder: Path) -> None: self._allowed = folder def arm(self) -> None: + """Starts refusing the programs the application tries to start.""" self._armed = True @property def refused(self) -> Tuple[str, ...]: + """The programs refused so far, each with its event and arguments, in order.""" with self._lock: return tuple(self._refused) diff --git a/tests/suite/screens/checks.py b/tests/suite/screens/checks.py index a7f86987e..b90410dad 100644 --- a/tests/suite/screens/checks.py +++ b/tests/suite/screens/checks.py @@ -8,7 +8,9 @@ from tests.suite.screens.boundaries.errors import ErrorRecords from tests.suite.screens.boundaries.spawns import SpawnGuard from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import WindowReading, is_tag_within, read_client_area, read_windows +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import WindowReading, read_windows +from tests.suite.screens.dearpygui.items.viewport import read_client_area class AfterCheckError(AssertionError): @@ -38,7 +40,9 @@ def quiet_findings( start: int, stop: Optional[int], ) -> List[str]: - """The unprovoked errors the application logged or let escape a thread, from the ``start``-th to the ``stop``-th.""" + """The unprovoked errors the application logged or let escape a thread, from the ``start``-th to the + ``stop``-th. + """ return [f"The application reported: {message}" for message in errors.unclaimed(start, stop)] diff --git a/tests/suite/screens/dearpygui/bridge.py b/tests/suite/screens/dearpygui/bridge.py index 1a77e6698..fae91c409 100644 --- a/tests/suite/screens/dearpygui/bridge.py +++ b/tests/suite/screens/dearpygui/bridge.py @@ -4,7 +4,6 @@ from typing import Callable, Final, Protocol, TypeVar AnswerT = TypeVar("AnswerT") - NEXT_DRAIN: Final[int] = 0 ONE_FRAME: Final[int] = 1 POLL_SECONDS: Final[float] = 0.05 diff --git a/tests/suite/screens/dearpygui/display.py b/tests/suite/screens/dearpygui/display.py index e20801e8a..d792bf0d8 100644 --- a/tests/suite/screens/dearpygui/display.py +++ b/tests/suite/screens/dearpygui/display.py @@ -1,21 +1,23 @@ import shutil from dataclasses import dataclass from enum import StrEnum -from typing import Dict, Final +from typing import Final from pyvirtualdisplay.display import Display +KEEP_STATE_BETWEEN_CLIENTS: Final[str] = "-noreset" + class DisplayBackend(StrEnum): + """The X server program a display runs on: Xvfb draws into memory, Xephyr into a window on the desktop.""" + XVFB = "xvfb" XEPHYR = "xephyr" - -SERVER_PROGRAMS: Final[Dict[DisplayBackend, str]] = { - DisplayBackend.XVFB: "Xvfb", - DisplayBackend.XEPHYR: "Xephyr", -} -KEEP_STATE_BETWEEN_CLIENTS: Final[str] = "-noreset" + @property + def program(self) -> str: + """The name of the X server program that draws for this backend.""" + return "Xvfb" if self is DisplayBackend.XVFB else "Xephyr" class DisplayServerMissingError(RuntimeError): @@ -48,7 +50,7 @@ def __init__( backend: DisplayBackend, size: ScreenSize, ) -> None: - self._program = SERVER_PROGRAMS[backend] + self._program = backend.program self._server = Display( backend=backend.value, size=(size.width, size.height), @@ -72,4 +74,5 @@ def start(self) -> str: return self._server.new_display_var def stop(self) -> None: + """Stops the server and frees the display name it served.""" self._server.stop() diff --git a/tests/suite/screens/dearpygui/gestures/__init__.py b/tests/suite/screens/dearpygui/gestures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/dearpygui/gestures/arrival.py b/tests/suite/screens/dearpygui/gestures/arrival.py new file mode 100644 index 000000000..aea0e4c4e --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/arrival.py @@ -0,0 +1,165 @@ +import time +from contextlib import suppress +from functools import partial +from typing import Callable, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge, RenderThreadStoppedError +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.gestures.constants import REACH_TIMEOUT_SECONDS +from tests.suite.screens.dearpygui.gestures.errors import GestureLostError +from tests.suite.screens.dearpygui.items.reading import read_hovered +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_pointer, read_viewport +from tests.suite.screens.dearpygui.keys import key_name +from tests.suite.screens.dearpygui.reach import UnreachableError, reachable +from tests.suite.screens.dearpygui.xtest import XTestDevice + +ARRIVAL_FRAMES: Final[int] = 10 +IMGUI_DOUBLE_CLICK_SECONDS: Final[float] = 0.30 + + +class Arrival: + """The part of a hand that lets frames pass and confirms that its input reached DearPyGui. + + It owns the bridge, the display's input device and the time of the last release. It aims at an item + once the item stands in reach and holds still, waits out a double-click window, and confirms + that the pointer, a button or a key reads as held. `Pointer` and `Keyboard` build their gestures + on these steps, and `Scrolling` reaches them through `Pointer`. + """ + + def __init__( + self, + bridge: Bridge, + device: XTestDevice, + ) -> None: + self._bridge = bridge + self._device = device + self._last_release: Optional[float] = None + + def _settle(self, count: int) -> None: + """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" + with suppress(RenderThreadStoppedError): + self._bridge.frames(count) + + def _application_seconds(self) -> Optional[float]: + """The application's clock, or ``None`` once a gesture has closed the application.""" + try: + return float(self._bridge.ask(dpg.get_total_time)) + except RenderThreadStoppedError: + return None + + def _outlast_a_double_click(self) -> None: + """Waits until the last press lies further back than a double-click reaches, in the application's clock. + + A person's next gesture comes later than that, while frames drawn quickly bring it within + reach, where Dear ImGui counts it with the clicks before it. + """ + last = self._last_release + if last is None: + return + + while float(self._bridge.ask(dpg.get_total_time)) - last < IMGUI_DOUBLE_CLICK_SECONDS: + self._bridge.frames(ONE_FRAME) + + def _on_display(self, point: Point) -> Point: + """The display pixel at ``point`` of the viewport.""" + viewport = self._bridge.ask(read_viewport) + return Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y) + + def _aim(self, item: Item) -> Point: + """The screen pixel at the middle of ``item``, once a user could press it and it holds still. + + A control takes frames to appear after the gesture that brings it, and a window sized by its + content settles its place over its first frames. The hand waits until the control is in reach + and stands where it stood a frame before, the way a person waits for a dialog to stop moving, + and reports why it stayed out of reach once the wait runs out. + """ + + def read() -> Point: + center = reachable(item).center + viewport = read_viewport() + return Point( + x=round(viewport.x) + center.x, + y=round(viewport.y) + center.y, + ) + + deadline = time.monotonic() + REACH_TIMEOUT_SECONDS + previous: Optional[Point] = None + while True: + try: + point = self._bridge.ask(read) + except UnreachableError: + if time.monotonic() >= deadline: + raise + point = None + + if point is not None and point == previous: + return point + if time.monotonic() >= deadline: + raise UnreachableError(f"{item!r} kept moving: it stood at {previous} and then at {point}") + + previous = point + self._bridge.frames(ONE_FRAME) + + def _hovered_within(self, item: Item, frames: int) -> bool: + """Whether ``item`` reports the pointer resting on it within ``frames`` frames, or reports no hover at all. + + An item standing in a tree that rebuilds reports no hover until the rebuild lets it go, which + is why the hover is aimed again until the reach time runs out. + """ + for _ in range(frames): + if self._bridge.ask(lambda: read_hovered(item)) is not False: + return True + + self._bridge.frames(ONE_FRAME) + + return False + + def _confirm_pointer(self, point: Point) -> None: + """Waits a few frames for the application to read the pointer at display pixel ``point``. + + Raises: + GestureLostError: If the application reads the pointer elsewhere throughout. + """ + viewport = self._bridge.ask(read_viewport) + expected = Point(x=point.x - round(viewport.x), y=point.y - round(viewport.y)) + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(read_pointer) == expected: + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"The pointer was moved to {expected} and the application reads it elsewhere") + + def _confirm_keys(self, keys: Sequence[int]) -> None: + for key in keys: + self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") + + def _confirm( + self, + is_down: Callable[[], bool], + what: str, + ) -> None: + """Checks that the application reads a held button or key as down, unless the gesture closed it. + + A display busy with other work hands the press on a few frames late, so the check reads it + once a frame for a few frames while it stays held. + + Raises: + GestureLostError: If the application reads it as up throughout. + """ + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(is_down): + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"{what} is held on the display and the application reads it as up") diff --git a/tests/suite/screens/dearpygui/gestures/constants.py b/tests/suite/screens/dearpygui/gestures/constants.py new file mode 100644 index 000000000..c21a8ae79 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +HOVER_FRAMES: Final[int] = 2 +HOLD_FRAMES: Final[int] = 2 +RELEASE_FRAMES: Final[int] = 1 +SETTLE_FRAMES: Final[int] = 2 +REACH_TIMEOUT_SECONDS: Final[float] = 10.0 diff --git a/tests/suite/screens/dearpygui/gestures/errors.py b/tests/suite/screens/dearpygui/gestures/errors.py new file mode 100644 index 000000000..6e4192d29 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/errors.py @@ -0,0 +1,2 @@ +class GestureLostError(AssertionError): + """Raised when a gesture's input never reached the control it was aimed at.""" diff --git a/tests/suite/screens/dearpygui/gestures/keyboard.py b/tests/suite/screens/dearpygui/gestures/keyboard.py new file mode 100644 index 000000000..69eccde0b --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/keyboard.py @@ -0,0 +1,57 @@ +from typing import Sequence + +from tests.suite.screens.dearpygui.gestures.arrival import Arrival +from tests.suite.screens.dearpygui.gestures.constants import HOLD_FRAMES, RELEASE_FRAMES, SETTLE_FRAMES +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT, keysym_of, keysym_of_character + + +class Keyboard(Arrival): + """The keyboard of a hand: key presses with modifiers, and typed text. + + It owns every gesture made with keys and builds each from `Arrival`'s steps: key down, confirm the + key reads as held, key up and settle. The full hand also uses it to replace the text of a field. + """ + + def press_key( + self, + key: int, + *, + modifiers: Sequence[int], + ) -> None: + """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + if modifiers: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + + self._device.key_down(keysym_of(key)) + self._settle(HOLD_FRAMES) + self._confirm_keys([key]) + self._device.key_up(keysym_of(key)) + self._settle(RELEASE_FRAMES) + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + + self._settle(SETTLE_FRAMES) + + def type_text(self, text: str) -> None: + """Types ``text`` into whatever holds the keyboard, one character a frame.""" + shift = keysym_of(IMGUI_LEFT_SHIFT) + for character in text: + keysym = keysym_of_character(character) + shifted = self._device.needs_shift(keysym) + if shifted: + self._device.key_down(shift) + + self._tap(keysym) + if shifted: + self._device.key_up(shift) + + self._settle(SETTLE_FRAMES) + + def _tap(self, keysym: int) -> None: + self._device.key_down(keysym) + self._settle(HOLD_FRAMES) + self._device.key_up(keysym) + self._settle(RELEASE_FRAMES) diff --git a/tests/suite/screens/dearpygui/gestures/pointer.py b/tests/suite/screens/dearpygui/gestures/pointer.py new file mode 100644 index 000000000..8d7174866 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/pointer.py @@ -0,0 +1,215 @@ +import time +from typing import Dict, Final, Sequence + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.gestures.arrival import Arrival +from tests.suite.screens.dearpygui.gestures.constants import ( + HOLD_FRAMES, + HOVER_FRAMES, + REACH_TIMEOUT_SECONDS, + RELEASE_FRAMES, + SETTLE_FRAMES, +) +from tests.suite.screens.dearpygui.gestures.errors import GestureLostError +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.keys import keysym_of +from tests.suite.screens.dearpygui.xtest import MouseButton + +SINGLE_PRESS: Final[int] = 1 +DOUBLE_PRESS: Final[int] = 2 +TRIPLE_PRESS: Final[int] = 3 +HOVER_ARRIVAL_FRAMES: Final[int] = 5 +DRAG_STEPS: Final[int] = 8 + +IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { + MouseButton.LEFT: dpg.mvMouseButton_Left, + MouseButton.RIGHT: dpg.mvMouseButton_Right, + MouseButton.MIDDLE: dpg.mvMouseButton_Middle, +} + + +class Pointer(Arrival): + """The pointer of a hand: presses, double-clicks, drags and hovering. + + It owns every gesture made with the buttons and builds each from `Arrival`'s steps: aim at the item, + rest over it, press, confirm the press and settle. `Scrolling` reuses its hover and drag to reach + scrollbars and the wheel. + """ + + def click(self, item: Item) -> None: + """Presses ``item`` with the left button.""" + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + + def click_holding( + self, + item: Item, + modifiers: Sequence[int], + ) -> None: + """Presses ``item`` with the left button while holding the Dear ImGui keys in ``modifiers``. + + The keys go down a frame before the button and come up once the press has settled, which is + how a person makes a Ctrl-click, so an application reading them while it answers the press + finds them held. + """ + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + self._settle(SETTLE_FRAMES) + + def double_click(self, item: Item) -> None: + """Presses ``item`` twice in quick succession with the left button.""" + self._press(item, MouseButton.LEFT, DOUBLE_PRESS) + + def double_click_held( + self, + item: Item, + *, + frames: int, + ) -> None: + """Presses ``item`` twice with the left button, holding the second press down for ``frames`` frames. + + A person lets go of a click some frames after pressing it, and the double-click is answered + as the second press goes down, so whatever the answer brings under the pointer meets a held + button. + """ + self.hover(item) + self._outlast_a_double_click() + self._press_once(MouseButton.LEFT, HOLD_FRAMES) + self._press_once(MouseButton.LEFT, frames) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def right_click(self, item: Item) -> None: + """Presses ``item`` with the right button, the gesture that opens a context menu.""" + self._press(item, MouseButton.RIGHT, SINGLE_PRESS) + + def click_at(self, point: Point) -> None: + """Presses the left button at ``point`` of the viewport, for a target DearPyGui reports a position alone for. + + The press confirms it reached the application, while what it landed on is for the scenario + to read from what the press changed. + """ + self._press_at(point, MouseButton.LEFT, SINGLE_PRESS) + + def double_click_at(self, point: Point) -> None: + """Presses the left button twice in quick succession at ``point`` of the viewport.""" + self._press_at(point, MouseButton.LEFT, DOUBLE_PRESS) + + def right_click_at(self, point: Point) -> None: + """Presses the right button at ``point`` of the viewport, for a menu a place offers rather than an item.""" + self._press_at(point, MouseButton.RIGHT, SINGLE_PRESS) + + def triple_click_at(self, point: Point) -> None: + """Presses the left button three times in quick succession at ``point`` of the viewport.""" + self._press_at(point, MouseButton.LEFT, TRIPLE_PRESS) + + def move_to(self, point: Point) -> None: + """Rests the pointer at ``point`` of the viewport.""" + self._device.move(self._on_display(point)) + self._settle(HOVER_FRAMES) + + def drag(self, start: Point, end: Point) -> None: + """Presses the left button at ``start`` of the viewport, carries it to ``end`` over a few frames, and lets go. + + The press is confirmed held as the pointer sets off, so a drag the application never saw + fails where it was lost. + """ + self._outlast_a_double_click() + self._drag_between(self._on_display(start), self._on_display(end)) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def hover(self, item: Item) -> None: + """Rests the pointer over ``item`` until the item reports it. + + Raises: + GestureLostError: If the item never reports the pointer resting on it. + """ + deadline = time.monotonic() + REACH_TIMEOUT_SECONDS + while True: + self._device.move(self._aim(item)) + self._bridge.frames(HOVER_FRAMES) + if self._hovered_within(item, HOVER_ARRIVAL_FRAMES): + return + if time.monotonic() >= deadline: + raise GestureLostError( + f"The pointer was aimed at {item!r} and the item reports no hover: " + f"something covers it, or the region it scrolls in clips it" + ) + + def _press( + self, + item: Item, + button: MouseButton, + count: int, + ) -> None: + self.hover(item) + self._outlast_a_double_click() + for _ in range(count): + self._press_once(button, HOLD_FRAMES) + + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _press_once( + self, + button: MouseButton, + frames: int, + ) -> None: + """Holds ``button`` down where the pointer stands for ``frames`` frames, then lets it go.""" + imgui_button = IMGUI_MOUSE_BUTTONS[button] + self._device.button_down(button) + self._settle(frames) + self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), f"The {button.name.lower()} button") + self._device.button_up(button) + self._settle(RELEASE_FRAMES) + + def _press_at( + self, + point: Point, + button: MouseButton, + count: int, + ) -> None: + self.move_to(point) + self._outlast_a_double_click() + for _ in range(count): + self._press_once(button, HOLD_FRAMES) + + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _drag_between(self, start: Point, end: Point) -> None: + """Presses the left button at display pixel ``start``, carries it to ``end`` over a few frames, and lets go. + + Each step waits until the application reads the pointer where the step put it, so every + point the drag passes through reaches the application while the button is down, and the + pointer rests at ``end`` a few frames before the button comes up, as a person stops before + letting go. + """ + self._device.move(start) + self._settle(HOVER_FRAMES) + self._device.button_down(MouseButton.LEFT) + self._settle(HOLD_FRAMES) + self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") + + for step in range(1, DRAG_STEPS + 1): + point = Point( + x=start.x + round((end.x - start.x) * step / DRAG_STEPS), + y=start.y + round((end.y - start.y) * step / DRAG_STEPS), + ) + self._device.move(point) + self._settle(ONE_FRAME) + self._confirm_pointer(point) + + self._settle(HOLD_FRAMES) + self._device.button_up(MouseButton.LEFT) + self._settle(RELEASE_FRAMES) diff --git a/tests/suite/screens/dearpygui/gestures/scrolling.py b/tests/suite/screens/dearpygui/gestures/scrolling.py new file mode 100644 index 000000000..0d5875dbc --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/scrolling.py @@ -0,0 +1,213 @@ +from functools import partial +from typing import Final + +from tests.suite.screens.dearpygui.bridge import ONE_FRAME +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.gestures.constants import HOVER_FRAMES, SETTLE_FRAMES +from tests.suite.screens.dearpygui.gestures.pointer import Pointer +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import ( + enclosing_regions, + read_region_view, + read_scroll, + read_visible_box, +) +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_viewport +from tests.suite.screens.dearpygui.reach import UnreachableError +from tests.suite.screens.dearpygui.xtest import MouseButton + +WHEEL_FRAMES: Final[int] = 1 +SCROLL_NOTCH_LIMIT: Final[int] = 100 +EDGE_INSET: Final[int] = 3 +GRIP_MARGIN_PIXELS: Final[float] = 8.0 +GRIP_DRAGS: Final[int] = 4 +IMGUI_GRAB_MIN_SIZE: Final[float] = 12.0 + + +class Scrolling(Pointer): + """The wheel and the scrollbars of a hand: turning the wheel over a region and bringing an item into view. + + It owns every gesture that moves a region's scroll. It rests the pointer with `Pointer`'s hover, + turns the wheel through the display's input device, and drags a scrollbar grip with `Pointer`'s drag + where the wheel leaves the region alone. + """ + + def wheel( + self, + item: Item, + notches: int, + ) -> None: + """Rests the pointer over ``item`` and turns the wheel ``notches`` notches, down for a positive count.""" + self.hover(item) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def wheel_at( + self, + point: Point, + notches: int, + ) -> None: + """Rests the pointer at viewport point ``point`` and turns the wheel ``notches`` notches, down if positive.""" + self.move_to(point) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def turn_wheel_over( + self, + region: Item, + notches: int, + ) -> None: + """Turns the wheel ``notches`` notches over the right edge of ``region``, down for a positive count. + + The pointer goes where it goes whatever stands open, so the gesture reports nothing; the + scenario reads what the turn moved. + """ + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def scroll_to_end(self, region: Item) -> None: + """Drags the grip of ``region``'s scrollbar to the bottom, as a person reaches the end of a long list. + + A list drawing only the rows in view settles its length as it scrolls, so the grip is dragged + again while the end moves on, and a turn of the wheel takes up the pixels the grip's whole + steps leave over. + + Raises: + UnreachableError: If the region stands short of its end afterwards. + """ + self.scroll_into_view(region) + for _ in range(GRIP_DRAGS): + before = self._bridge.ask(lambda: read_scroll(region)) + if before.position >= before.maximum: + return + + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.maximum + GRIP_MARGIN_PIXELS)) + self._drag_between(start, end) + self._settle(SETTLE_FRAMES) + + short = self._bridge.ask(lambda: read_scroll(region)) + if short.position < short.maximum and short.by_wheel: + self.turn_wheel_over(region, 1) + + after = self._bridge.ask(lambda: read_scroll(region)) + if after.position < after.maximum: + raise UnreachableError(f"The region {region!r} stands short of its end at {after}") + + def scroll_into_view(self, item: Item) -> None: + """Turns the wheel over the regions ``item`` scrolls in until the item stands whole in view. + + The nearest region that scrolls is moved first, at its right edge, and the regions around it + after. The wheel moves a region that takes it: the first notch shows how far one notch carries + the region, and each later turn takes as many notches as the distance left needs, the way a + person spins the wheel toward a row. A region the wheel leaves alone is moved by dragging its + scrollbar's grip to where the grip stands for the scroll the item needs, and a few pixels on, + since the grip moves in whole pixels and each of them scrolls further than one. + + Raises: + UnreachableError: If a region stops moving, or the wheel runs out of turns, first. + """ + for region in self._bridge.ask(lambda: enclosing_regions(item)): + self._scroll_within(item, region) + + self._settle(SETTLE_FRAMES) + + def _scroll_within(self, item: Item, region: Item) -> None: + notch = 0.0 + for _ in range(SCROLL_NOTCH_LIMIT): + distance = self._bridge.ask(lambda: _distance_from_view(item, region)) + before = self._bridge.ask(lambda: read_scroll(region)) + if distance == 0 or before.maximum == 0: + return + + if before.by_wheel: + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 + self._turn_wheel(notches if distance > 0 else -notches) + else: + margin = GRIP_MARGIN_PIXELS if distance > 0 else -GRIP_MARGIN_PIXELS + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.position + distance + margin)) + self._drag_between(start, end) + notches = 1 + + self._settle(ONE_FRAME) + after = self._bridge.ask(lambda: read_scroll(region)) + if after == before: + raise UnreachableError(f"{item!r} stays out of view: the region {region!r} stopped at {before}") + + notch = abs(after.position - before.position) / notches + + raise UnreachableError(f"{item!r} stays out of view after {SCROLL_NOTCH_LIMIT} turns of the wheel") + + def _turn_wheel(self, notches: int) -> None: + button = MouseButton.WHEEL_DOWN if notches > 0 else MouseButton.WHEEL_UP + for _ in range(abs(notches)): + self._device.button_down(button) + self._settle(WHEEL_FRAMES) + self._device.button_up(button) + self._settle(WHEEL_FRAMES) + + +def _distance_from_view(item: Item, region: Item) -> float: + """How far the region must scroll for ``item`` to stand whole in its view: down for a positive distance. + + Runs on the render thread. + """ + item_box = read_item(item).rect + region_box = read_region_view(region) + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + below = item_box.y + item_box.height - (region_box.y + region_box.height) + if below > 0: + return below + above = item_box.y - region_box.y + if above < 0: + return above + + return 0.0 + + +def _wheel_point(region: Item) -> Point: + """A pixel at the right edge of the part of ``region`` left in view, where its scrollbar stands. + + The middle of a region often lies over a smaller region it holds, which would take the wheel; + the edge belongs to the region itself. Runs on the render thread. + """ + visible = read_visible_box(region) + if visible is None: + raise UnreachableError(f"No part of the region {region!r} stands in view to turn the wheel over") + + viewport = read_viewport() + return Point( + x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, + y=round(viewport.y) + visible.center.y, + ) + + +def _grip_point(region: Item, scroll: float) -> Point: + """The pixel at the middle of ``region``'s scrollbar grip while the region stands scrolled to ``scroll``. + + The grip spans the share of the content the region shows, held at Dear ImGui's smallest grip, + and travels the rest of the track as the region scrolls from its top to its end. The track spans + the region's whole height, so the region stands whole in view for its grip to be dragged. Runs on + the render thread. + """ + box = read_item(region).rect + if box is None: + raise UnreachableError(f"The region {region!r} reports no box to drag its scrollbar in") + + reading = read_scroll(region) + target = min(max(scroll, 0.0), reading.maximum) + grip = min(max(box.height * box.height / (box.height + reading.maximum), IMGUI_GRAB_MIN_SIZE), box.height) + travel = box.height - grip + viewport = read_viewport() + return Point( + x=round(viewport.x + box.x + box.width) - EDGE_INSET, + y=round(viewport.y + box.y + grip / 2 + travel * target / reading.maximum), + ) diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index e78ade58f..522e6327e 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -1,65 +1,10 @@ -import time -from contextlib import suppress -from functools import partial -from typing import Callable, Dict, Final, Optional, Sequence +from tests.suite.screens.dearpygui.gestures.keyboard import Keyboard +from tests.suite.screens.dearpygui.gestures.scrolling import Scrolling +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.keys import IMGUI_BACKSPACE, IMGUI_LEFT_CTRL, IMGUI_LETTER_A -import dearpygui.dearpygui as dpg -from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge, RenderThreadStoppedError -from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.items import ( - Item, - enclosing_regions, - read_hovered, - read_item, - read_pointer, - read_region_view, - read_scroll, - read_viewport, - read_visible_box, -) -from tests.suite.screens.dearpygui.keys import ( - IMGUI_BACKSPACE, - IMGUI_LEFT_CTRL, - IMGUI_LEFT_SHIFT, - IMGUI_LETTER_A, - key_name, - keysym_of, - keysym_of_character, -) -from tests.suite.screens.dearpygui.reach import UnreachableError, reachable -from tests.suite.screens.dearpygui.xtest import MouseButton, XTestDevice - -HOVER_FRAMES: Final[int] = 2 -HOLD_FRAMES: Final[int] = 2 -RELEASE_FRAMES: Final[int] = 1 -SETTLE_FRAMES: Final[int] = 2 -SINGLE_PRESS: Final[int] = 1 -DOUBLE_PRESS: Final[int] = 2 -TRIPLE_PRESS: Final[int] = 3 -HOVER_ARRIVAL_FRAMES: Final[int] = 5 -ARRIVAL_FRAMES: Final[int] = 10 -REACH_TIMEOUT_SECONDS: Final[float] = 10.0 -WHEEL_FRAMES: Final[int] = 1 -SCROLL_NOTCH_LIMIT: Final[int] = 100 -EDGE_INSET: Final[int] = 3 -IMGUI_DOUBLE_CLICK_SECONDS: Final[float] = 0.30 -DRAG_STEPS: Final[int] = 8 -GRIP_MARGIN_PIXELS: Final[float] = 8.0 -GRIP_DRAGS: Final[int] = 4 -IMGUI_GRAB_MIN_SIZE: Final[float] = 12.0 -IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { - MouseButton.LEFT: dpg.mvMouseButton_Left, - MouseButton.RIGHT: dpg.mvMouseButton_Right, - MouseButton.MIDDLE: dpg.mvMouseButton_Middle, -} - - -class GestureLostError(AssertionError): - """Raised when a gesture's input never reached the control it was aimed at.""" - - -class Hand: +class Hand(Scrolling, Keyboard): """A user's hand on the application: the pointer and the keyboard of the scenario's display. A gesture first reads where its control stands, refusing one beyond a user's reach, and then @@ -72,257 +17,10 @@ class Hand: button goes down, and the application reports every held button and named key down while it is held, so a press lost on the way fails where it was lost, and a scenario expecting nothing to happen learns that its gesture was made. - """ - - def __init__( - self, - bridge: Bridge, - device: XTestDevice, - ) -> None: - self._bridge = bridge - self._device = device - self._last_release: Optional[float] = None - - def click(self, item: Item) -> None: - """Presses ``item`` with the left button.""" - self._press(item, MouseButton.LEFT, SINGLE_PRESS) - - def click_holding( - self, - item: Item, - modifiers: Sequence[int], - ) -> None: - """Presses ``item`` with the left button while holding the Dear ImGui keys in ``modifiers``. - - The keys go down a frame before the button and come up once the press has settled, which is - how a person makes a Ctrl-click, so an application reading them while it answers the press - finds them held. - """ - for modifier in modifiers: - self._device.key_down(keysym_of(modifier)) - self._settle(HOLD_FRAMES) - self._confirm_keys(modifiers) - - self._press(item, MouseButton.LEFT, SINGLE_PRESS) - - for modifier in reversed(modifiers): - self._device.key_up(keysym_of(modifier)) - self._settle(SETTLE_FRAMES) - - def double_click(self, item: Item) -> None: - """Presses ``item`` twice in quick succession with the left button.""" - self._press(item, MouseButton.LEFT, DOUBLE_PRESS) - - def double_click_held( - self, - item: Item, - *, - frames: int, - ) -> None: - """Presses ``item`` twice with the left button, holding the second press down for ``frames`` frames. - - A person lets go of a click some frames after pressing it, and the double-click is answered - as the second press goes down, so whatever the answer brings under the pointer meets a held - button. - """ - self.hover(item) - self._outlast_a_double_click() - self._press_once(MouseButton.LEFT, HOLD_FRAMES) - self._press_once(MouseButton.LEFT, frames) - self._last_release = self._application_seconds() - self._settle(SETTLE_FRAMES) - - def right_click(self, item: Item) -> None: - """Presses ``item`` with the right button, the gesture that opens a context menu.""" - self._press(item, MouseButton.RIGHT, SINGLE_PRESS) - - def click_at(self, point: Point) -> None: - """Presses the left button at ``point`` of the viewport, for a target DearPyGui reports a position alone for. - - The press confirms it reached the application, while what it landed on is for the scenario - to read from what the press changed. - """ - self._press_at(point, MouseButton.LEFT, SINGLE_PRESS) - - def double_click_at(self, point: Point) -> None: - """Presses the left button twice in quick succession at ``point`` of the viewport.""" - self._press_at(point, MouseButton.LEFT, DOUBLE_PRESS) - - def right_click_at(self, point: Point) -> None: - """Presses the right button at ``point`` of the viewport, for a menu a place offers rather than an item.""" - self._press_at(point, MouseButton.RIGHT, SINGLE_PRESS) - - def triple_click_at(self, point: Point) -> None: - """Presses the left button three times in quick succession at ``point`` of the viewport.""" - self._press_at(point, MouseButton.LEFT, TRIPLE_PRESS) - - def move_to(self, point: Point) -> None: - """Rests the pointer at ``point`` of the viewport.""" - self._device.move(self._on_display(point)) - self._settle(HOVER_FRAMES) - - def drag(self, start: Point, end: Point) -> None: - """Presses the left button at ``start`` of the viewport, carries it to ``end`` over a few frames, and lets go. - - The press is confirmed held as the pointer sets off, so a drag the application never saw - fails where it was lost. - """ - self._outlast_a_double_click() - self._drag_between(self._on_display(start), self._on_display(end)) - self._last_release = self._application_seconds() - self._settle(SETTLE_FRAMES) - - def hover(self, item: Item) -> None: - """Rests the pointer over ``item`` until the item reports it. - Raises: - GestureLostError: If the item never reports the pointer resting on it. - """ - deadline = time.monotonic() + REACH_TIMEOUT_SECONDS - while True: - self._device.move(self._aim(item)) - self._bridge.frames(HOVER_FRAMES) - if self._hovered_within(item, HOVER_ARRIVAL_FRAMES): - return - if time.monotonic() >= deadline: - raise GestureLostError( - f"The pointer was aimed at {item!r} and the item reports no hover: " - f"something covers it, or the region it scrolls in clips it" - ) - - def wheel( - self, - item: Item, - notches: int, - ) -> None: - """Rests the pointer over ``item`` and turns the wheel ``notches`` notches, down for a positive count.""" - self.hover(item) - self._turn_wheel(notches) - self._settle(SETTLE_FRAMES) - - def wheel_at( - self, - point: Point, - notches: int, - ) -> None: - """Rests the pointer at viewport point ``point`` and turns the wheel ``notches`` notches, down if positive.""" - self.move_to(point) - self._turn_wheel(notches) - self._settle(SETTLE_FRAMES) - - def turn_wheel_over( - self, - region: Item, - notches: int, - ) -> None: - """Turns the wheel ``notches`` notches over the right edge of ``region``, down for a positive count. - - The pointer goes where it goes whatever stands open, so the gesture reports nothing; the - scenario reads what the turn moved. - """ - self._device.move(self._bridge.ask(lambda: _wheel_point(region))) - self._settle(HOVER_FRAMES) - self._turn_wheel(notches) - self._settle(SETTLE_FRAMES) - - def scroll_to_end(self, region: Item) -> None: - """Drags the grip of ``region``'s scrollbar to the bottom, as a person reaches the end of a long list. - - A list drawing only the rows in view settles its length as it scrolls, so the grip is dragged - again while the end moves on, and a turn of the wheel takes up the pixels the grip's whole - steps leave over. - - Raises: - UnreachableError: If the region stands short of its end afterwards. - """ - self.scroll_into_view(region) - for _ in range(GRIP_DRAGS): - before = self._bridge.ask(lambda: read_scroll(region)) - if before.position >= before.maximum: - return - - start = self._bridge.ask(partial(_grip_point, region, before.position)) - end = self._bridge.ask(partial(_grip_point, region, before.maximum + GRIP_MARGIN_PIXELS)) - self._drag_between(start, end) - self._settle(SETTLE_FRAMES) - - short = self._bridge.ask(lambda: read_scroll(region)) - if short.position < short.maximum and short.by_wheel: - self.turn_wheel_over(region, 1) - - after = self._bridge.ask(lambda: read_scroll(region)) - if after.position < after.maximum: - raise UnreachableError(f"The region {region!r} stands short of its end at {after}") - - def scroll_into_view(self, item: Item) -> None: - """Turns the wheel over the regions ``item`` scrolls in until the item stands whole in view. - - The nearest region that scrolls is moved first, at its right edge, and the regions around it - after. The wheel moves a region that takes it: the first notch shows how far one notch carries - the region, and each later turn takes as many notches as the distance left needs, the way a - person spins the wheel toward a row. A region the wheel leaves alone is moved by dragging its - scrollbar's grip to where the grip stands for the scroll the item needs, and a few pixels on, - since the grip moves in whole pixels and each of them scrolls further than one. - - Raises: - UnreachableError: If a region stops moving, or the wheel runs out of turns, first. - """ - for region in self._bridge.ask(lambda: enclosing_regions(item)): - self._scroll_within(item, region) - - self._settle(SETTLE_FRAMES) - - def _scroll_within(self, item: Item, region: Item) -> None: - notch = 0.0 - for _ in range(SCROLL_NOTCH_LIMIT): - distance = self._bridge.ask(lambda: _distance_from_view(item, region)) - before = self._bridge.ask(lambda: read_scroll(region)) - if distance == 0 or before.maximum == 0: - return - - if before.by_wheel: - self._device.move(self._bridge.ask(lambda: _wheel_point(region))) - self._settle(HOVER_FRAMES) - notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 - self._turn_wheel(notches if distance > 0 else -notches) - else: - margin = GRIP_MARGIN_PIXELS if distance > 0 else -GRIP_MARGIN_PIXELS - start = self._bridge.ask(partial(_grip_point, region, before.position)) - end = self._bridge.ask(partial(_grip_point, region, before.position + distance + margin)) - self._drag_between(start, end) - notches = 1 - - self._settle(ONE_FRAME) - after = self._bridge.ask(lambda: read_scroll(region)) - if after == before: - raise UnreachableError(f"{item!r} stays out of view: the region {region!r} stopped at {before}") - - notch = abs(after.position - before.position) / notches - - raise UnreachableError(f"{item!r} stays out of view after {SCROLL_NOTCH_LIMIT} turns of the wheel") - - def press_key( - self, - key: int, - *, - modifiers: Sequence[int], - ) -> None: - """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" - for modifier in modifiers: - self._device.key_down(keysym_of(modifier)) - if modifiers: - self._settle(HOLD_FRAMES) - self._confirm_keys(modifiers) - - self._device.key_down(keysym_of(key)) - self._settle(HOLD_FRAMES) - self._confirm_keys([key]) - self._device.key_up(keysym_of(key)) - self._settle(RELEASE_FRAMES) - for modifier in reversed(modifiers): - self._device.key_up(keysym_of(modifier)) - - self._settle(SETTLE_FRAMES) + The pieces are `Arrival` (waiting and confirming), `Pointer` (presses and drags), `Scrolling` + (the wheel and the scrollbars) and `Keyboard` (keys and typed text). + """ def replace_text( self, @@ -334,286 +32,3 @@ def replace_text( self.press_key(IMGUI_LETTER_A, modifiers=[IMGUI_LEFT_CTRL]) self.press_key(IMGUI_BACKSPACE, modifiers=[]) self.type_text(text) - - def type_text(self, text: str) -> None: - """Types ``text`` into whatever holds the keyboard, one character a frame.""" - shift = keysym_of(IMGUI_LEFT_SHIFT) - for character in text: - keysym = keysym_of_character(character) - shifted = self._device.needs_shift(keysym) - if shifted: - self._device.key_down(shift) - - self._tap(keysym) - if shifted: - self._device.key_up(shift) - - self._settle(SETTLE_FRAMES) - - def _press( - self, - item: Item, - button: MouseButton, - count: int, - ) -> None: - self.hover(item) - self._outlast_a_double_click() - for _ in range(count): - self._press_once(button, HOLD_FRAMES) - - self._last_release = self._application_seconds() - self._settle(SETTLE_FRAMES) - - def _press_once( - self, - button: MouseButton, - frames: int, - ) -> None: - """Holds ``button`` down where the pointer stands for ``frames`` frames, then lets it go.""" - imgui_button = IMGUI_MOUSE_BUTTONS[button] - self._device.button_down(button) - self._settle(frames) - self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), f"The {button.name.lower()} button") - self._device.button_up(button) - self._settle(RELEASE_FRAMES) - - def _outlast_a_double_click(self) -> None: - """Waits until the last press lies further back than a double-click reaches, in the application's clock. - - A person's next gesture comes later than that, while frames drawn quickly bring it within - reach, where Dear ImGui counts it with the clicks before it. - """ - last = self._last_release - if last is None: - return - - while float(self._bridge.ask(dpg.get_total_time)) - last < IMGUI_DOUBLE_CLICK_SECONDS: - self._bridge.frames(ONE_FRAME) - - def _application_seconds(self) -> Optional[float]: - """The application's clock, or ``None`` once a gesture has closed the application.""" - try: - return float(self._bridge.ask(dpg.get_total_time)) - except RenderThreadStoppedError: - return None - - def _press_at( - self, - point: Point, - button: MouseButton, - count: int, - ) -> None: - self.move_to(point) - self._outlast_a_double_click() - for _ in range(count): - self._press_once(button, HOLD_FRAMES) - - self._last_release = self._application_seconds() - self._settle(SETTLE_FRAMES) - - def _on_display(self, point: Point) -> Point: - """The display pixel at ``point`` of the viewport.""" - viewport = self._bridge.ask(read_viewport) - return Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y) - - def _drag_between(self, start: Point, end: Point) -> None: - """Presses the left button at display pixel ``start``, carries it to ``end`` over a few frames, and lets go. - - Each step waits until the application reads the pointer where the step put it, so every - point the drag passes through reaches the application while the button is down, and the - pointer rests at ``end`` a few frames before the button comes up, as a person stops before - letting go. - """ - self._device.move(start) - self._settle(HOVER_FRAMES) - self._device.button_down(MouseButton.LEFT) - self._settle(HOLD_FRAMES) - self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") - - for step in range(1, DRAG_STEPS + 1): - point = Point( - x=start.x + round((end.x - start.x) * step / DRAG_STEPS), - y=start.y + round((end.y - start.y) * step / DRAG_STEPS), - ) - self._device.move(point) - self._settle(ONE_FRAME) - self._confirm_pointer(point) - - self._settle(HOLD_FRAMES) - self._device.button_up(MouseButton.LEFT) - self._settle(RELEASE_FRAMES) - - def _turn_wheel(self, notches: int) -> None: - button = MouseButton.WHEEL_DOWN if notches > 0 else MouseButton.WHEEL_UP - for _ in range(abs(notches)): - self._device.button_down(button) - self._settle(WHEEL_FRAMES) - self._device.button_up(button) - self._settle(WHEEL_FRAMES) - - def _tap(self, keysym: int) -> None: - self._device.key_down(keysym) - self._settle(HOLD_FRAMES) - self._device.key_up(keysym) - self._settle(RELEASE_FRAMES) - - def _hovered_within(self, item: Item, frames: int) -> bool: - """Whether ``item`` reports the pointer resting on it within ``frames`` frames, or reports no hover at all. - - An item standing in a tree that rebuilds reports no hover until the rebuild lets it go, which - is why the hover is aimed again until the reach time runs out. - """ - for _ in range(frames): - if self._bridge.ask(lambda: read_hovered(item)) is not False: - return True - - self._bridge.frames(ONE_FRAME) - - return False - - def _confirm_pointer(self, point: Point) -> None: - """Waits a few frames for the application to read the pointer at display pixel ``point``. - - Raises: - GestureLostError: If the application reads the pointer elsewhere throughout. - """ - viewport = self._bridge.ask(read_viewport) - expected = Point(x=point.x - round(viewport.x), y=point.y - round(viewport.y)) - for _ in range(ARRIVAL_FRAMES): - try: - if self._bridge.ask(read_pointer) == expected: - return - except RenderThreadStoppedError: - return - - self._settle(ONE_FRAME) - - raise GestureLostError(f"The pointer was moved to {expected} and the application reads it elsewhere") - - def _confirm_keys(self, keys: Sequence[int]) -> None: - for key in keys: - self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") - - def _confirm( - self, - is_down: Callable[[], bool], - what: str, - ) -> None: - """Checks that the application reads a held button or key as down, unless the gesture closed it. - - A display busy with other work hands the press on a few frames late, so the check reads it - once a frame for a few frames while it stays held. - - Raises: - GestureLostError: If the application reads it as up throughout. - """ - for _ in range(ARRIVAL_FRAMES): - try: - if self._bridge.ask(is_down): - return - except RenderThreadStoppedError: - return - - self._settle(ONE_FRAME) - - raise GestureLostError(f"{what} is held on the display and the application reads it as up") - - def _settle(self, count: int) -> None: - """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" - with suppress(RenderThreadStoppedError): - self._bridge.frames(count) - - def _aim(self, item: Item) -> Point: - """The screen pixel at the middle of ``item``, once a user could press it and it holds still. - - A control takes frames to appear after the gesture that brings it, and a window sized by its - content settles its place over its first frames. The hand waits until the control is in reach - and stands where it stood a frame before, the way a person waits for a dialog to stop moving, - and reports why it stayed out of reach once the wait runs out. - """ - - def read() -> Point: - center = reachable(item).center - viewport = read_viewport() - return Point( - x=round(viewport.x) + center.x, - y=round(viewport.y) + center.y, - ) - - deadline = time.monotonic() + REACH_TIMEOUT_SECONDS - previous: Optional[Point] = None - while True: - try: - point = self._bridge.ask(read) - except UnreachableError: - if time.monotonic() >= deadline: - raise - point = None - - if point is not None and point == previous: - return point - if time.monotonic() >= deadline: - raise UnreachableError(f"{item!r} kept moving: it stood at {previous} and then at {point}") - - previous = point - self._bridge.frames(ONE_FRAME) - - -def _distance_from_view(item: Item, region: Item) -> float: - """How far the region must scroll for ``item`` to stand whole in its view: down for a positive distance. - - Runs on the render thread. - """ - item_box = read_item(item).rect - region_box = read_region_view(region) - if item_box is None or region_box is None: - raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") - - below = item_box.y + item_box.height - (region_box.y + region_box.height) - if below > 0: - return below - above = item_box.y - region_box.y - if above < 0: - return above - - return 0.0 - - -def _wheel_point(region: Item) -> Point: - """A pixel at the right edge of the part of ``region`` left in view, where its scrollbar stands. - - The middle of a region often lies over a smaller region it holds, which would take the wheel; - the edge belongs to the region itself. Runs on the render thread. - """ - visible = read_visible_box(region) - if visible is None: - raise UnreachableError(f"No part of the region {region!r} stands in view to turn the wheel over") - - viewport = read_viewport() - return Point( - x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, - y=round(viewport.y) + visible.center.y, - ) - - -def _grip_point(region: Item, scroll: float) -> Point: - """The pixel at the middle of ``region``'s scrollbar grip while the region stands scrolled to ``scroll``. - - The grip spans the share of the content the region shows, held at Dear ImGui's smallest grip, - and travels the rest of the track as the region scrolls from its top to its end. The track spans - the region's whole height, so the region stands whole in view for its grip to be dragged. Runs on - the render thread. - """ - box = read_item(region).rect - if box is None: - raise UnreachableError(f"The region {region!r} reports no box to drag its scrollbar in") - - reading = read_scroll(region) - target = min(max(scroll, 0.0), reading.maximum) - grip = min(max(box.height * box.height / (box.height + reading.maximum), IMGUI_GRAB_MIN_SIZE), box.height) - travel = box.height - grip - viewport = read_viewport() - return Point( - x=round(viewport.x + box.x + box.width) - EDGE_INSET, - y=round(viewport.y + box.y + grip / 2 + travel * target / reading.maximum), - ) diff --git a/tests/suite/screens/dearpygui/isolation.py b/tests/suite/screens/dearpygui/isolation.py index 0ca53d7f9..04b4fafc8 100644 --- a/tests/suite/screens/dearpygui/isolation.py +++ b/tests/suite/screens/dearpygui/isolation.py @@ -9,6 +9,7 @@ import pytest PHASES: Final[Sequence[Literal["setup", "call", "teardown"]]] = ("setup", "call", "teardown") + CHILD_ARGUMENTS: Final[Sequence[str]] = ( "-p", "no:cacheprovider", @@ -19,6 +20,7 @@ "-q", "--no-header", ) + OUTPUT_TAIL_CHARACTERS: Final[int] = 20_000 NODE_SEPARATOR: Final[str] = "::" XFAIL_MARKER: Final[str] = "xfail" @@ -70,6 +72,7 @@ def __init__( @pytest.hookimpl def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + """Appends the report of one finished phase to the file as a line of JSON.""" data = self._config.hook.pytest_report_to_serializable(config=self._config, report=report) with self._path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(data) + "\n") diff --git a/tests/suite/screens/dearpygui/items.py b/tests/suite/screens/dearpygui/items.py deleted file mode 100644 index 098d560a1..000000000 --- a/tests/suite/screens/dearpygui/items.py +++ /dev/null @@ -1,567 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple, Union - -import dearpygui.dearpygui as dpg - -from tests.suite.screens.dearpygui.geometry import Point, Rect - -Item = Union[int, str] - -WINDOW_TYPE: Final[str] = "mvAppItemType::mvWindowAppItem" -CHILD_WINDOW_TYPE: Final[str] = "mvAppItemType::mvChildWindow" -TREE_NODE_TYPE: Final[str] = "mvAppItemType::mvTreeNode" -MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" -MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" -TEXT_TYPE: Final[str] = "mvAppItemType::mvText" -BUTTON_TYPE: Final[str] = "mvAppItemType::mvButton" -THEME_COLOR_TYPE: Final[str] = "mvAppItemType::mvThemeColor" -COLOR_ATTRIBUTES: Final[Tuple[str, ...]] = ("color", "fill") -THEME_COLOR_VALUE: Final[str] = "value" -THEME_TARGET: Final[str] = "target" -THEME_CATEGORY: Final[str] = "category" -COLOR_PARTS: Final[int] = 4 -COLOR_DIGITS: Final[int] = 4 -COLOR_STEPS: Final[float] = 255.0 -TAG_SEPARATOR: Final[str] = "." - - -@dataclass(frozen=True) -class ItemReading: - """What one item showed in the last frame: whether it stands, where, and whether it answers. - - Attributes: - item: The item read. - exists: Whether the item is in the context at all. - shown: Whether the item and every container around it are shown. - visible: Whether the item was drawn inside its clip region in the last frame. - enabled: Whether the item and every container around it answer a press. - rect: The item's box on the screen, where its kind reports one. - """ - - item: Item - exists: bool - shown: bool - visible: bool - enabled: bool - rect: Optional[Rect] - - -@dataclass(frozen=True) -class ScrollReading: - """How far a region that scrolls stands from its top, how far it can go, in pixels, and what moves it. - - Attributes: - position: How far the region is scrolled down. - maximum: How far the region can scroll down. - by_wheel: Whether the wheel scrolls the region, or its scrollbar alone does. - """ - - position: float - maximum: float - by_wheel: bool - - -@dataclass(frozen=True) -class WindowReading: - """One top-level window as the last frame drew it. - - Attributes: - alias: The window's tag. - label: The title the window carries. - modal: Whether the window holds the screen while it stands. - shown: Whether the window is shown. - rect: The window's box on the screen. - """ - - alias: str - label: str - modal: bool - shown: bool - rect: Rect - - -def read_item(item: Item) -> ItemReading: - """Reads ``item`` as the last frame drew it. Runs on the render thread.""" - if not dpg.does_item_exist(item): - return ItemReading( - item=item, - exists=False, - shown=False, - visible=False, - enabled=False, - rect=None, - ) - - state = dpg.get_item_state(item) - configuration = dpg.get_item_configuration(item) - return ItemReading( - item=item, - exists=True, - shown=_shown_with_ancestors(item), - visible=bool(state.get("visible", True)), - enabled=bool(configuration.get("enabled", True)) and _enabled_ancestors(item), - rect=_rect(item, state), - ) - - -def read_windows() -> Tuple[WindowReading, ...]: - """Reads every top-level window the context holds. Runs on the render thread.""" - windows = [] - for window in dpg.get_windows(): - if dpg.get_item_info(window)["type"] != WINDOW_TYPE: - continue - - configuration = dpg.get_item_configuration(window) - windows.append( - WindowReading( - alias=str(dpg.get_item_alias(window)), - label=str(configuration.get("label") or ""), - modal=bool(configuration.get("modal")), - shown=bool(dpg.is_item_shown(window)), - rect=_window_rect(window), - ) - ) - - return tuple(windows) - - -def read_scroll(region: Item) -> ScrollReading: - """How far ``region`` is scrolled down. Runs on the render thread.""" - return ScrollReading( - position=dpg.get_y_scroll(region), - maximum=dpg.get_y_scroll_max(region), - by_wheel=not dpg.get_item_configuration(region).get("no_scroll_with_mouse", False), - ) - - -def enclosing_regions(item: Item) -> Tuple[Item, ...]: - """The regions around ``item`` that clip what they hold, the nearest first. Runs on the render thread.""" - regions: List[Item] = [] - parent = dpg.get_item_parent(item) - while parent is not None: - if dpg.get_item_info(parent)["type"] == CHILD_WINDOW_TYPE: - regions.append(parent) - - parent = dpg.get_item_parent(parent) - - return tuple(regions) - - -def read_region_view(region: Item) -> Optional[Rect]: - """The part of ``region``'s box its content shows through, its scrollbars and far padding left out. - - Runs on the render thread. A region reports the room left for its content, which is its box - less its padding on both sides and the scrollbars standing in it, so the view keeps the near - side whole and gives up half of what the region withholds on the far side, which covers its - scrollbar. - """ - box = read_item(region).rect - if box is None: - return None - - room = dpg.get_item_state(region)["content_region_avail"] - return Rect( - x=box.x, - y=box.y, - width=box.width - (box.width - room[0]) / 2, - height=box.height - (box.height - room[1]) / 2, - ) - - -def read_visible_box(item: Item) -> Optional[Rect]: - """The part of ``item``'s box the regions around it and the viewport leave in view. Runs on the render thread.""" - box = read_item(item).rect - for region in enclosing_regions(item): - view = read_region_view(region) - if box is None or view is None: - return None - - box = box.overlap(view) - - return box.overlap(read_client_area()) if box is not None else None - - -def read_table(table: Item) -> Tuple[Tuple[Tuple[Item, ...], ...], ...]: - """The items inside each cell of each row of ``table``, row by row. Runs on the render thread. - - A table keeps its columns in one slot of children and its rows in another, and each row holds - one cell per column, whatever item the cell carries. - """ - rows = dpg.get_item_info(table)["children"][1] - return tuple( - tuple(tuple(dpg.get_item_info(cell)["children"][1]) for cell in dpg.get_item_info(row)["children"][1]) - for row in rows - ) - - -def find_item( - container: Item, - matches: Callable[[Item], bool], -) -> Optional[Item]: - """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. - - Runs on the render thread. - """ - pending: List[Item] = [container] - while pending: - item = pending.pop(0) - if matches(item): - return item - - pending[:0] = [child for children in dpg.get_item_info(item)["children"].values() for child in children] - - return None - - -def read_item_count() -> int: - """How many items the context holds, which grows with what the interface builds. Runs on the render thread.""" - return len(dpg.get_all_items()) - - -@dataclass(frozen=True) -class EntryReading: - """One entry of a popup menu: its words, where it stands in the viewport, and whether it answers.""" - - label: str - point: Point - enabled: bool - - -def read_popup_entries(popup: Item) -> Tuple[EntryReading, ...]: - """The menu entries a popup window holds, in order. Runs on the render thread. - - DearPyGui reports an entry's position inside the popup alone, so the entry stands where the - popup does, moved by that position. - """ - corner = dpg.get_item_pos(popup) - entries: List[EntryReading] = [] - for child in dpg.get_item_children(popup, 1): - if dpg.get_item_info(child)["type"] != MENU_ITEM_TYPE: - continue - - position = dpg.get_item_state(child)["pos"] - entries.append( - EntryReading( - label=read_label(child), - point=Point(x=round(corner[0] + position[0]), y=round(corner[1] + position[1])), - enabled=bool(dpg.get_item_configuration(child).get("enabled", True)), - ) - ) - - return tuple(entries) - - -def read_hovered(item: Item) -> Optional[bool]: - """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. - - Runs on the render thread. An item covered by a popup, clipped by the region it scrolls in, or - passed over while another item holds the mouse reads as not hovered. - """ - state = dpg.get_item_state(item) - if "hovered" not in state: - return None - - return bool(state["hovered"]) - - -def read_theme(item: Item) -> Optional[str]: - """The tag of the theme bound to ``item``, if one is. Runs on the render thread.""" - theme = dpg.get_item_info(item)["theme"] - if theme is None: - return None - - return str(dpg.get_item_alias(theme)) - - -def read_label(item: Item) -> str: - """The label ``item`` carries. Runs on the render thread.""" - return str(dpg.get_item_label(item) or "") - - -def read_value(item: Item) -> Any: - """The value ``item`` holds, whatever kind the item keeps. Runs on the render thread.""" - return dpg.get_value(item) - - -def read_shown_texts(container: Item) -> Tuple[str, ...]: - """The words of every shown text item under ``container``, in the order they are drawn. - - Runs on the render thread. - """ - return _texts(container, shown_only=True) - - -def read_texts(container: Item) -> Tuple[str, ...]: - """The words of every text item under ``container``, shown or waiting to be, such as a tooltip's. - - Runs on the render thread. - """ - return _texts(container, shown_only=False) - - -def read_theme_colors(theme: Item) -> Tuple[Tuple[float, ...], ...]: - """The colors every theme color under ``theme`` holds, in the order they were added. Runs on the render thread.""" - colors: List[Tuple[float, ...]] = [] - for component in dpg.get_item_children(theme, 1): - for color in dpg.get_item_children(component, 1): - colors.append(tuple(float(part) for part in dpg.get_value(color))) - - return tuple(colors) - - -def read_held_colors() -> Dict[str, Tuple[float, ...]]: - """Every color DearPyGui holds for the interface, keyed by the item and the attribute holding it. Runs on the - render thread. - - A theme color holds its value whether or not anything wearing it is drawn; an item holds its own color and fill - while it stands shown. Every value reads as fractions of the full step, rounded, so a color given in whole steps - reads the same from either side. - """ - held: Dict[str, Tuple[float, ...]] = {} - for item in dpg.get_all_items(): - if dpg.get_item_info(item)["type"] == THEME_COLOR_TYPE: - held[f"{item}:{THEME_COLOR_VALUE}"] = _theme_color(item) - continue - - if not dpg.is_item_shown(item): - continue - - configuration = dpg.get_item_configuration(item) - for attribute in COLOR_ATTRIBUTES: - value = configuration.get(attribute) - if _is_color(value): - held[f"{item}:{attribute}"] = rounded_color(value) - - return held - - -def read_theme_text_color(theme: Item) -> Optional[Tuple[float, ...]]: - """The text color ``theme`` sets, if it sets one. Runs on the render thread.""" - for component in dpg.get_item_children(theme, 1): - for color in dpg.get_item_children(component, 1): - configuration = dpg.get_item_configuration(color) - if ( - configuration.get(THEME_TARGET) == dpg.mvThemeCol_Text - and configuration.get(THEME_CATEGORY) == dpg.mvThemeCat_Core - ): - return _theme_color(color) - - return None - - -def rounded_color(value: Sequence[float]) -> Tuple[float, ...]: - """A color's parts, rounded so a color read from DearPyGui and one stated in whole steps compare equal.""" - return tuple(round(float(part), COLOR_DIGITS) for part in value) - - -def _theme_color(item: Item) -> Tuple[float, ...]: - """The color a theme color item holds, as a fraction of the full step like an item's own color reads. - - DearPyGui answers a theme color in whole steps and an item's color in fractions of one. - """ - return rounded_color(tuple(float(part) / COLOR_STEPS for part in dpg.get_value(item))) - - -def _is_color(value: object) -> bool: - """Whether a configuration value is a color DearPyGui holds, which an unset one, read as negatives, is not.""" - return ( - isinstance(value, (list, tuple)) - and len(value) == COLOR_PARTS - and all(isinstance(part, (int, float)) and part >= 0 for part in value) - ) - - -def read_visible_text(words: str) -> bool: - """Whether a text item reading ``words`` was drawn in the last frame, such as a tooltip standing open. - - Runs on the render thread. - """ - return any( - dpg.get_item_info(item)["type"] == TEXT_TYPE - and dpg.get_value(item) == words - and bool(dpg.is_item_visible(item)) - for item in dpg.get_all_items() - ) - - -def read_shown_labels(container: Item, item_type: str) -> Tuple[str, ...]: - """The labels of every shown item of ``item_type`` under ``container``, in the order they are drawn. - - Runs on the render thread. - """ - labels: List[str] = [] - pending: List[Item] = [container] - while pending: - item = pending.pop(0) - if not dpg.is_item_shown(item): - continue - - info = dpg.get_item_info(item) - if info["type"] == item_type: - labels.append(read_label(item)) - - pending[:0] = [child for children in info["children"].values() for child in children] - - return tuple(labels) - - -def _texts(container: Item, *, shown_only: bool) -> Tuple[str, ...]: - texts: List[str] = [] - pending: List[Item] = [container] - while pending: - item = pending.pop(0) - if shown_only and not dpg.is_item_shown(item): - continue - - info = dpg.get_item_info(item) - if info["type"] == TEXT_TYPE: - texts.append(str(dpg.get_value(item))) - - pending[:0] = [child for children in info["children"].values() for child in children] - - return tuple(texts) - - -def find_labelled( - container: Item, - label: str, - *, - item_type: str, -) -> Item: - """The first item of ``item_type`` under ``container`` whose label reads ``label``. Runs on the render thread. - - A label may carry padding around its words, so the words are compared with the padding stripped. - - Raises: - LookupError: If no such item stands under ``container``. - """ - pending: List[Item] = [container] - while pending: - item = pending.pop(0) - info = dpg.get_item_info(item) - if info["type"] == item_type and read_label(item).strip() == label.strip(): - return item - - for children in info["children"].values(): - pending.extend(children) - - raise LookupError(f"No {item_type} labelled '{label}' stands under {container!r}") - - -def read_selected_tab(tab_bar: str) -> str: - """The tag of the tab standing in front of ``tab_bar``. Runs on the render thread.""" - return str(dpg.get_item_alias(dpg.get_value(tab_bar))) - - -def read_viewport() -> Rect: - """The viewport's client area, placed where it stands on the screen. Runs on the render thread.""" - corner = dpg.get_viewport_pos() - return Rect( - x=corner[0], - y=corner[1], - width=dpg.get_viewport_client_width(), - height=dpg.get_viewport_client_height(), - ) - - -def read_viewport_decorated() -> bool: - """Whether the window wears the system's title bar and frame. Runs on the render thread.""" - return bool(dpg.is_viewport_decorated()) - - -def read_viewport_title() -> str: - """The title the viewport's window carries in its title bar. Runs on the render thread.""" - return str(dpg.get_viewport_title()) - - -def read_pointer() -> Point: - """Where the pointer stands in the viewport, as the application last saw it. Runs on the render thread.""" - position = dpg.get_mouse_pos(local=False) - return Point(x=round(position[0]), y=round(position[1])) - - -def read_client_area() -> Rect: - """The viewport's client area in the coordinates its items report. Runs on the render thread.""" - return Rect( - x=0, - y=0, - width=dpg.get_viewport_client_width(), - height=dpg.get_viewport_client_height(), - ) - - -def is_tag_within(alias: str, prefix: str) -> bool: - """Whether ``alias`` is ``prefix`` itself or a tag composed under it.""" - return alias == prefix or alias.startswith(prefix + TAG_SEPARATOR) - - -def _shown_with_ancestors(tag: Item) -> bool: - item: Optional[Item] = tag - while item is not None: - if not dpg.is_item_shown(item): - return False - - item = dpg.get_item_parent(item) - - return True - - -def _enabled_ancestors(tag: Item) -> bool: - parent = dpg.get_item_parent(tag) - while parent is not None: - if not dpg.get_item_configuration(parent).get("enabled", True): - return False - - parent = dpg.get_item_parent(parent) - - return True - - -def _rect(item: Item, state: Dict[str, Any]) -> Optional[Rect]: - if "rect_min" in state: - return Rect.of(state["rect_min"], state["rect_size"]) - - item_type = dpg.get_item_info(item)["type"] - if item_type == WINDOW_TYPE: - return _window_rect(item) - if item_type == CHILD_WINDOW_TYPE: - return Rect.of(_child_window_corner(item), state["rect_size"]) - - return None - - -def _child_window_corner(child: Item) -> Tuple[float, float]: - """Where a child window stands on the screen. - - DearPyGui reports a child window's position in the content of the window holding it, which - scrolls, so the corner is that window's own corner moved by the position and back by the scroll. - """ - container = _containing_window(child) - if dpg.get_item_info(container)["type"] == WINDOW_TYPE: - corner = dpg.get_item_pos(container) - else: - corner = _child_window_corner(container) - - position = dpg.get_item_state(child)["pos"] - return ( - corner[0] + position[0] - dpg.get_x_scroll(container), - corner[1] + position[1] - dpg.get_y_scroll(container), - ) - - -def _containing_window(item: Item) -> Item: - parent: Optional[Item] = dpg.get_item_parent(item) - while parent is not None and dpg.get_item_info(parent)["type"] not in (WINDOW_TYPE, CHILD_WINDOW_TYPE): - parent = dpg.get_item_parent(parent) - - if parent is None: - raise LookupError(f"{item!r} stands in no window") - - return parent - - -def _window_rect(window: Item) -> Rect: - return Rect.of( - dpg.get_item_pos(window), - dpg.get_item_state(window)["rect_size"], - ) diff --git a/tests/suite/screens/dearpygui/items/__init__.py b/tests/suite/screens/dearpygui/items/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/dearpygui/items/colors.py b/tests/suite/screens/dearpygui/items/colors.py new file mode 100644 index 000000000..4f158d9ad --- /dev/null +++ b/tests/suite/screens/dearpygui/items/colors.py @@ -0,0 +1,95 @@ +from typing import Dict, Final, List, Optional, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.items.types import Item + +THEME_COLOR_TYPE: Final[str] = "mvAppItemType::mvThemeColor" +COLOR_ATTRIBUTES: Final[Tuple[str, ...]] = ("color", "fill") +THEME_COLOR_VALUE: Final[str] = "value" +THEME_TARGET: Final[str] = "target" +THEME_CATEGORY: Final[str] = "category" +COLOR_PARTS: Final[int] = 4 +COLOR_DIGITS: Final[int] = 4 +COLOR_STEPS: Final[float] = 255.0 + + +def read_theme(item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, if one is. Runs on the render thread.""" + theme = dpg.get_item_info(item)["theme"] + if theme is None: + return None + + return str(dpg.get_item_alias(theme)) + + +def read_theme_colors(theme: Item) -> Tuple[Tuple[float, ...], ...]: + """The colors every theme color under ``theme`` holds, in the order they were added. Runs on the render thread.""" + colors: List[Tuple[float, ...]] = [] + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + colors.append(tuple(float(part) for part in dpg.get_value(color))) + + return tuple(colors) + + +def read_held_colors() -> Dict[str, Tuple[float, ...]]: + """Every color DearPyGui holds for the interface, keyed by the item and the attribute holding it. Runs on the + render thread. + + A theme color holds its value whether or not anything wearing it is drawn; an item holds its own color and fill + while it stands shown. Every value reads as fractions of the full step, rounded, so a color given in whole steps + reads the same from either side. + """ + held: Dict[str, Tuple[float, ...]] = {} + for item in dpg.get_all_items(): + if dpg.get_item_info(item)["type"] == THEME_COLOR_TYPE: + held[f"{item}:{THEME_COLOR_VALUE}"] = _theme_color(item) + continue + + if not dpg.is_item_shown(item): + continue + + configuration = dpg.get_item_configuration(item) + for attribute in COLOR_ATTRIBUTES: + value = configuration.get(attribute) + if _is_color(value): + held[f"{item}:{attribute}"] = rounded_color(value) + + return held + + +def read_theme_text_color(theme: Item) -> Optional[Tuple[float, ...]]: + """The text color ``theme`` sets, if it sets one. Runs on the render thread.""" + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + configuration = dpg.get_item_configuration(color) + if ( + configuration.get(THEME_TARGET) == dpg.mvThemeCol_Text + and configuration.get(THEME_CATEGORY) == dpg.mvThemeCat_Core + ): + return _theme_color(color) + + return None + + +def rounded_color(value: Sequence[float]) -> Tuple[float, ...]: + """A color's parts, rounded so a color read from DearPyGui and one stated in whole steps compare equal.""" + return tuple(round(float(part), COLOR_DIGITS) for part in value) + + +def _theme_color(item: Item) -> Tuple[float, ...]: + """The color a theme color item holds, as a fraction of the full step like an item's own color reads. + + DearPyGui answers a theme color in whole steps and an item's color in fractions of one. + """ + return rounded_color(tuple(float(part) / COLOR_STEPS for part in dpg.get_value(item))) + + +def _is_color(value: object) -> bool: + """Whether a configuration value is a color DearPyGui holds, which an unset one, read as negatives, is not.""" + return ( + isinstance(value, (list, tuple)) + and len(value) == COLOR_PARTS + and all(isinstance(part, (int, float)) and part >= 0 for part in value) + ) diff --git a/tests/suite/screens/dearpygui/items/reading.py b/tests/suite/screens/dearpygui/items/reading.py new file mode 100644 index 000000000..cd4b6223d --- /dev/null +++ b/tests/suite/screens/dearpygui/items/reading.py @@ -0,0 +1,178 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.types import CHILD_WINDOW_TYPE, WINDOW_TYPE, Item + +TAG_SEPARATOR: Final[str] = "." + + +@dataclass(frozen=True) +class ItemReading: + """What one item showed in the last frame: whether it stands, where, and whether it answers. + + Attributes: + item: The item read. + exists: Whether the item is in the context at all. + shown: Whether the item and every container around it are shown. + visible: Whether the item was drawn inside its clip region in the last frame. + enabled: Whether the item and every container around it answer a press. + rect: The item's box on the screen, where its kind reports one. + """ + + item: Item + exists: bool + shown: bool + visible: bool + enabled: bool + rect: Optional[Rect] + + +def read_item(item: Item) -> ItemReading: + """Reads ``item`` as the last frame drew it. Runs on the render thread.""" + if not dpg.does_item_exist(item): + return ItemReading( + item=item, + exists=False, + shown=False, + visible=False, + enabled=False, + rect=None, + ) + + state = dpg.get_item_state(item) + configuration = dpg.get_item_configuration(item) + return ItemReading( + item=item, + exists=True, + shown=_shown_with_ancestors(item), + visible=bool(state.get("visible", True)), + enabled=bool(configuration.get("enabled", True)) and _enabled_ancestors(item), + rect=_rect(item, state), + ) + + +def find_item( + container: Item, + matches: Callable[[Item], bool], +) -> Optional[Item]: + """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. + + Runs on the render thread. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if matches(item): + return item + + pending[:0] = [child for children in dpg.get_item_info(item)["children"].values() for child in children] + + return None + + +def read_item_count() -> int: + """How many items the context holds, which grows with what the interface builds. Runs on the render thread.""" + return len(dpg.get_all_items()) + + +def read_hovered(item: Item) -> Optional[bool]: + """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. + + Runs on the render thread. An item covered by a popup, clipped by the region it scrolls in, or + passed over while another item holds the mouse reads as not hovered. + """ + state = dpg.get_item_state(item) + if "hovered" not in state: + return None + + return bool(state["hovered"]) + + +def read_value(item: Item) -> Any: + """The value ``item`` holds, whatever kind the item keeps. Runs on the render thread.""" + return dpg.get_value(item) + + +def read_selected_tab(tab_bar: str) -> str: + """The tag of the tab standing in front of ``tab_bar``. Runs on the render thread.""" + return str(dpg.get_item_alias(dpg.get_value(tab_bar))) + + +def is_tag_within(alias: str, prefix: str) -> bool: + """Whether ``alias`` is ``prefix`` itself or a tag composed under it.""" + return alias == prefix or alias.startswith(prefix + TAG_SEPARATOR) + + +def _shown_with_ancestors(tag: Item) -> bool: + item: Optional[Item] = tag + while item is not None: + if not dpg.is_item_shown(item): + return False + + item = dpg.get_item_parent(item) + + return True + + +def _enabled_ancestors(tag: Item) -> bool: + parent = dpg.get_item_parent(tag) + while parent is not None: + if not dpg.get_item_configuration(parent).get("enabled", True): + return False + + parent = dpg.get_item_parent(parent) + + return True + + +def _rect(item: Item, state: Dict[str, Any]) -> Optional[Rect]: + if "rect_min" in state: + return Rect.of(state["rect_min"], state["rect_size"]) + + item_type = dpg.get_item_info(item)["type"] + if item_type == WINDOW_TYPE: + return _window_rect(item) + if item_type == CHILD_WINDOW_TYPE: + return Rect.of(_child_window_corner(item), state["rect_size"]) + + return None + + +def _child_window_corner(child: Item) -> Tuple[float, float]: + """Where a child window stands on the screen. + + DearPyGui reports a child window's position in the content of the window holding it, which + scrolls, so the corner is that window's own corner moved by the position and back by the scroll. + """ + container = _containing_window(child) + if dpg.get_item_info(container)["type"] == WINDOW_TYPE: + corner = dpg.get_item_pos(container) + else: + corner = _child_window_corner(container) + + position = dpg.get_item_state(child)["pos"] + return ( + corner[0] + position[0] - dpg.get_x_scroll(container), + corner[1] + position[1] - dpg.get_y_scroll(container), + ) + + +def _containing_window(item: Item) -> Item: + parent: Optional[Item] = dpg.get_item_parent(item) + while parent is not None and dpg.get_item_info(parent)["type"] not in (WINDOW_TYPE, CHILD_WINDOW_TYPE): + parent = dpg.get_item_parent(parent) + + if parent is None: + raise LookupError(f"{item!r} stands in no window") + + return parent + + +def _window_rect(window: Item) -> Rect: + return Rect.of( + dpg.get_item_pos(window), + dpg.get_item_state(window)["rect_size"], + ) diff --git a/tests/suite/screens/dearpygui/items/regions.py b/tests/suite/screens/dearpygui/items/regions.py new file mode 100644 index 000000000..fe62c0c39 --- /dev/null +++ b/tests/suite/screens/dearpygui/items/regions.py @@ -0,0 +1,133 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Rect +from tests.suite.screens.dearpygui.items.reading import _window_rect, read_item +from tests.suite.screens.dearpygui.items.types import CHILD_WINDOW_TYPE, WINDOW_TYPE, Item +from tests.suite.screens.dearpygui.items.viewport import read_client_area + + +@dataclass(frozen=True) +class ScrollReading: + """How far a region that scrolls stands from its top, how far it can go, in pixels, and what moves it. + + Attributes: + position: How far the region is scrolled down. + maximum: How far the region can scroll down. + by_wheel: Whether the wheel scrolls the region, or its scrollbar alone does. + """ + + position: float + maximum: float + by_wheel: bool + + +@dataclass(frozen=True) +class WindowReading: + """One top-level window as the last frame drew it. + + Attributes: + alias: The window's tag. + label: The title the window carries. + modal: Whether the window holds the screen while it stands. + shown: Whether the window is shown. + rect: The window's box on the screen. + """ + + alias: str + label: str + modal: bool + shown: bool + rect: Rect + + +def read_windows() -> Tuple[WindowReading, ...]: + """Reads every top-level window the context holds. Runs on the render thread.""" + windows = [] + for window in dpg.get_windows(): + if dpg.get_item_info(window)["type"] != WINDOW_TYPE: + continue + + configuration = dpg.get_item_configuration(window) + windows.append( + WindowReading( + alias=str(dpg.get_item_alias(window)), + label=str(configuration.get("label") or ""), + modal=bool(configuration.get("modal")), + shown=bool(dpg.is_item_shown(window)), + rect=_window_rect(window), + ) + ) + + return tuple(windows) + + +def read_scroll(region: Item) -> ScrollReading: + """How far ``region`` is scrolled down. Runs on the render thread.""" + return ScrollReading( + position=dpg.get_y_scroll(region), + maximum=dpg.get_y_scroll_max(region), + by_wheel=not dpg.get_item_configuration(region).get("no_scroll_with_mouse", False), + ) + + +def enclosing_regions(item: Item) -> Tuple[Item, ...]: + """The regions around ``item`` that clip what they hold, the nearest first. Runs on the render thread.""" + regions: List[Item] = [] + parent = dpg.get_item_parent(item) + while parent is not None: + if dpg.get_item_info(parent)["type"] == CHILD_WINDOW_TYPE: + regions.append(parent) + + parent = dpg.get_item_parent(parent) + + return tuple(regions) + + +def read_region_view(region: Item) -> Optional[Rect]: + """The part of ``region``'s box its content shows through, its scrollbars and far padding left out. + + Runs on the render thread. A region reports the room left for its content, which is its box + less its padding on both sides and the scrollbars standing in it, so the view keeps the near + side whole and gives up half of what the region withholds on the far side, which covers its + scrollbar. + """ + box = read_item(region).rect + if box is None: + return None + + room = dpg.get_item_state(region)["content_region_avail"] + return Rect( + x=box.x, + y=box.y, + width=box.width - (box.width - room[0]) / 2, + height=box.height - (box.height - room[1]) / 2, + ) + + +def read_visible_box(item: Item) -> Optional[Rect]: + """The part of ``item``'s box the regions around it and the viewport leave in view. Runs on the render thread.""" + box = read_item(item).rect + for region in enclosing_regions(item): + view = read_region_view(region) + if box is None or view is None: + return None + + box = box.overlap(view) + + return box.overlap(read_client_area()) if box is not None else None + + +def read_table(table: Item) -> Tuple[Tuple[Tuple[Item, ...], ...], ...]: + """The items inside each cell of each row of ``table``, row by row. Runs on the render thread. + + A table keeps its columns in one slot of children and its rows in another, and each row holds + one cell per column, whatever item the cell carries. + """ + rows = dpg.get_item_info(table)["children"][1] + return tuple( + tuple(tuple(dpg.get_item_info(cell)["children"][1]) for cell in dpg.get_item_info(row)["children"][1]) + for row in rows + ) diff --git a/tests/suite/screens/dearpygui/items/texts.py b/tests/suite/screens/dearpygui/items/texts.py new file mode 100644 index 000000000..884c3b4ac --- /dev/null +++ b/tests/suite/screens/dearpygui/items/texts.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Point +from tests.suite.screens.dearpygui.items.types import MENU_ITEM_TYPE, TEXT_TYPE, Item + + +@dataclass(frozen=True) +class EntryReading: + """One entry of a popup menu: its words, where it stands in the viewport, and whether it answers.""" + + label: str + point: Point + enabled: bool + + +def read_popup_entries(popup: Item) -> Tuple[EntryReading, ...]: + """The menu entries a popup window holds, in order. Runs on the render thread. + + DearPyGui reports an entry's position inside the popup alone, so the entry stands where the + popup does, moved by that position. + """ + corner = dpg.get_item_pos(popup) + entries: List[EntryReading] = [] + for child in dpg.get_item_children(popup, 1): + if dpg.get_item_info(child)["type"] != MENU_ITEM_TYPE: + continue + + position = dpg.get_item_state(child)["pos"] + entries.append( + EntryReading( + label=read_label(child), + point=Point(x=round(corner[0] + position[0]), y=round(corner[1] + position[1])), + enabled=bool(dpg.get_item_configuration(child).get("enabled", True)), + ) + ) + + return tuple(entries) + + +def read_label(item: Item) -> str: + """The label ``item`` carries. Runs on the render thread.""" + return str(dpg.get_item_label(item) or "") + + +def read_shown_texts(container: Item) -> Tuple[str, ...]: + """The words of every shown text item under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + return _texts(container, shown_only=True) + + +def read_texts(container: Item) -> Tuple[str, ...]: + """The words of every text item under ``container``, shown or waiting to be, such as a tooltip's. + + Runs on the render thread. + """ + return _texts(container, shown_only=False) + + +def read_visible_text(words: str) -> bool: + """Whether a text item reading ``words`` was drawn in the last frame, such as a tooltip standing open. + + Runs on the render thread. + """ + return any( + dpg.get_item_info(item)["type"] == TEXT_TYPE + and dpg.get_value(item) == words + and bool(dpg.is_item_visible(item)) + for item in dpg.get_all_items() + ) + + +def read_shown_labels(container: Item, item_type: str) -> Tuple[str, ...]: + """The labels of every shown item of ``item_type`` under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + labels: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == item_type: + labels.append(read_label(item)) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(labels) + + +def _texts(container: Item, *, shown_only: bool) -> Tuple[str, ...]: + texts: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if shown_only and not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == TEXT_TYPE: + texts.append(str(dpg.get_value(item))) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(texts) + + +def find_labelled( + container: Item, + label: str, + *, + item_type: str, +) -> Item: + """The first item of ``item_type`` under ``container`` whose label reads ``label``. Runs on the render thread. + + A label may carry padding around its words, so the words are compared with the padding stripped. + + Raises: + LookupError: If no such item stands under ``container``. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + info = dpg.get_item_info(item) + if info["type"] == item_type and read_label(item).strip() == label.strip(): + return item + + for children in info["children"].values(): + pending.extend(children) + + raise LookupError(f"No {item_type} labelled '{label}' stands under {container!r}") diff --git a/tests/suite/screens/dearpygui/items/types.py b/tests/suite/screens/dearpygui/items/types.py new file mode 100644 index 000000000..fd941fed0 --- /dev/null +++ b/tests/suite/screens/dearpygui/items/types.py @@ -0,0 +1,10 @@ +from typing import Final, Union + +Item = Union[int, str] +WINDOW_TYPE: Final[str] = "mvAppItemType::mvWindowAppItem" +CHILD_WINDOW_TYPE: Final[str] = "mvAppItemType::mvChildWindow" +TREE_NODE_TYPE: Final[str] = "mvAppItemType::mvTreeNode" +MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" +MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" +TEXT_TYPE: Final[str] = "mvAppItemType::mvText" +BUTTON_TYPE: Final[str] = "mvAppItemType::mvButton" diff --git a/tests/suite/screens/dearpygui/items/viewport.py b/tests/suite/screens/dearpygui/items/viewport.py new file mode 100644 index 000000000..ca918f879 --- /dev/null +++ b/tests/suite/screens/dearpygui/items/viewport.py @@ -0,0 +1,40 @@ +import dearpygui.dearpygui as dpg + +from tests.suite.screens.dearpygui.geometry import Point, Rect + + +def read_viewport() -> Rect: + """The viewport's client area, placed where it stands on the screen. Runs on the render thread.""" + corner = dpg.get_viewport_pos() + return Rect( + x=corner[0], + y=corner[1], + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) + + +def read_viewport_decorated() -> bool: + """Whether the window wears the system's title bar and frame. Runs on the render thread.""" + return bool(dpg.is_viewport_decorated()) + + +def read_viewport_title() -> str: + """The title the viewport's window carries in its title bar. Runs on the render thread.""" + return str(dpg.get_viewport_title()) + + +def read_pointer() -> Point: + """Where the pointer stands in the viewport, as the application last saw it. Runs on the render thread.""" + position = dpg.get_mouse_pos(local=False) + return Point(x=round(position[0]), y=round(position[1])) + + +def read_client_area() -> Rect: + """The viewport's client area in the coordinates its items report. Runs on the render thread.""" + return Rect( + x=0, + y=0, + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) diff --git a/tests/suite/screens/dearpygui/keys.py b/tests/suite/screens/dearpygui/keys.py index 2b37ad463..b040ce0fc 100644 --- a/tests/suite/screens/dearpygui/keys.py +++ b/tests/suite/screens/dearpygui/keys.py @@ -23,7 +23,6 @@ IMGUI_DIGIT_ZERO: Final[int] = 536 IMGUI_LETTER_A: Final[int] = 546 IMGUI_F1: Final[int] = 572 - DIGIT_COUNT: Final[int] = 10 LETTER_COUNT: Final[int] = 26 FUNCTION_KEY_COUNT: Final[int] = 12 @@ -48,11 +47,15 @@ IMGUI_LEFT_SHIFT: "Shift_L", IMGUI_LEFT_ALT: "Alt_L", } + DIGIT_KEYSYMS: Final[Dict[int, str]] = {IMGUI_DIGIT_ZERO + offset: str(offset) for offset in range(DIGIT_COUNT)} + LETTER_KEYSYMS: Final[Dict[int, str]] = { IMGUI_LETTER_A + offset: chr(ord("a") + offset) for offset in range(LETTER_COUNT) } + FUNCTION_KEYSYMS: Final[Dict[int, str]] = {IMGUI_F1 + offset: f"F{offset + 1}" for offset in range(FUNCTION_KEY_COUNT)} + KEYSYM_NAMES: Final[Dict[int, str]] = { **NAMED_KEYSYMS, **DIGIT_KEYSYMS, diff --git a/tests/suite/screens/dearpygui/reach.py b/tests/suite/screens/dearpygui/reach.py index 7843bf8d7..77eee2d3f 100644 --- a/tests/suite/screens/dearpygui/reach.py +++ b/tests/suite/screens/dearpygui/reach.py @@ -3,14 +3,10 @@ import dearpygui.dearpygui as dpg from tests.suite.screens.dearpygui.geometry import Rect -from tests.suite.screens.dearpygui.items import ( - Item, - ItemReading, - read_client_area, - read_item, - read_visible_box, - read_windows, -) +from tests.suite.screens.dearpygui.items.reading import ItemReading, read_item +from tests.suite.screens.dearpygui.items.regions import read_visible_box, read_windows +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_client_area class UnreachableError(AssertionError): diff --git a/tests/suite/screens/dearpygui/recording.py b/tests/suite/screens/dearpygui/recording.py index a34c6c24c..2911ff9e6 100644 --- a/tests/suite/screens/dearpygui/recording.py +++ b/tests/suite/screens/dearpygui/recording.py @@ -7,7 +7,6 @@ from tests.suite.screens.dearpygui.bridge import ONE_FRAME, RenderThread ReadingT = TypeVar("ReadingT") - NO_FRAME: Final[int] = 0 @@ -39,17 +38,21 @@ def __init__( self._lock = threading.Lock() def start(self) -> None: + """Takes the first reading after the next frame and goes on reading after every frame.""" self._render_thread.post(self._take, frames=ONE_FRAME) def stop(self) -> None: + """Ends the recording; the readings taken so far stay available.""" self._stopped.set() @property def readings(self) -> Tuple[FrameReading[ReadingT], ...]: + """Every reading taken so far, in frame order, each with the frame it followed.""" with self._lock: return tuple(self._readings) def values(self) -> List[ReadingT]: + """The value of every reading taken so far, in frame order.""" return [reading.value for reading in self.readings] def _take(self) -> None: diff --git a/tests/suite/screens/dearpygui/semantic.py b/tests/suite/screens/dearpygui/semantic.py index 3606f3c72..81d7fe027 100644 --- a/tests/suite/screens/dearpygui/semantic.py +++ b/tests/suite/screens/dearpygui/semantic.py @@ -3,11 +3,11 @@ import dearpygui.dearpygui as dpg -from tests.suite.screens.dearpygui.items import Item, ItemReading, read_item +from tests.suite.screens.dearpygui.items.reading import ItemReading, read_item +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.dearpygui.reach import UnreachableError, covering_modal CallbackRunner = Callable[..., object] - POSITIONAL: Final = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) diff --git a/tests/suite/screens/dearpygui/windows.py b/tests/suite/screens/dearpygui/windows.py index 38afd31d3..696b3ac54 100644 --- a/tests/suite/screens/dearpygui/windows.py +++ b/tests/suite/screens/dearpygui/windows.py @@ -51,6 +51,7 @@ def request_close(self) -> None: self._display.sync() def close(self) -> None: + """Closes the connection to the display.""" self._display.close() def _own_window(self) -> Window: diff --git a/tests/suite/screens/dearpygui/xtest.py b/tests/suite/screens/dearpygui/xtest.py index f32ac4919..27da498e4 100644 --- a/tests/suite/screens/dearpygui/xtest.py +++ b/tests/suite/screens/dearpygui/xtest.py @@ -11,6 +11,8 @@ class MouseButton(IntEnum): + """A pointer button as the X server numbers it, the two wheel directions included.""" + LEFT = 1 MIDDLE = 2 RIGHT = 3 @@ -27,8 +29,8 @@ class XTestDevice: Each call sends one event and flushes it, so the server holds it before the next frame polls. The display is the scenario's own, which keeps every event inside the application under test. - The display repeats no held key, so a key held across a slow frame arrives as the one press a - person made. + The display turns key auto-repeat off, so a key held across a slow frame arrives as the one + press a person made. """ def __init__(self, display_name: str) -> None: @@ -37,22 +39,27 @@ def __init__(self, display_name: str) -> None: self._display.sync() def move(self, point: Point) -> None: + """Moves the pointer to ``point``, in display pixels.""" xtest.fake_input(self._display, X.MotionNotify, x=point.x, y=point.y) self._display.sync() def button_down(self, button: MouseButton) -> None: + """Presses ``button`` and holds it down.""" xtest.fake_input(self._display, X.ButtonPress, int(button)) self._display.sync() def button_up(self, button: MouseButton) -> None: + """Releases ``button``.""" xtest.fake_input(self._display, X.ButtonRelease, int(button)) self._display.sync() def key_down(self, keysym: int) -> None: + """Presses the key typing ``keysym`` and holds it down.""" xtest.fake_input(self._display, X.KeyPress, self._keycode(keysym)) self._display.sync() def key_up(self, keysym: int) -> None: + """Releases the key typing ``keysym``.""" xtest.fake_input(self._display, X.KeyRelease, self._keycode(keysym)) self._display.sync() @@ -64,6 +71,7 @@ def needs_shift(self, keysym: int) -> bool: ) def close(self) -> None: + """Closes the connection to the display.""" self._display.close() def _keycode(self, keysym: int) -> int: diff --git a/tests/suite/screens/holds.py b/tests/suite/screens/holds.py deleted file mode 100644 index 54c25fae6..000000000 --- a/tests/suite/screens/holds.py +++ /dev/null @@ -1,295 +0,0 @@ -import itertools -import threading -import time -from dataclasses import dataclass -from functools import partial -from pathlib import Path -from typing import AbstractSet, Callable, Final, Iterator, List, Optional, Protocol, Sequence - -import pytest - -import sampletones_application.logic.main.sources.scan as scan_module -import sampletones_core.reconstructions.converter.converter as converter_module -from sampletones_application.services.export.reporter import ExportProgressReporter -from sampletones_application.services.regeneration.service import RegenerationService -from sampletones_application.services.result import ServiceError, ServiceProgress -from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters import Features -from sampletones_core.exports.stage import ExportStage -from sampletones_core.reconstructions import Reconstruction -from sampletones_core.reconstructions.progress import STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, announce -from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig -from sampletones_core.reconstructions.stage import ReconstructionStage -from sampletones_shared.types.path import Pathlike -from tests.suite.conversion import COUNTED_STAGES, FAKE_FRAMES, HALFWAY - -RELEASE_POLL_SECONDS: Final[float] = 0.05 -HELD_ENTRY: Final[str] = "held-entry.txt" -FIRST_REPORT: Final[int] = 0 - - -class Hold(Protocol): - """Work a scenario keeps under way until it lets go, so a gesture lands while the work runs.""" - - def release(self) -> None: - """Lets the work carry on to its end.""" - - -class Holds: - """Every hold a scenario put on the application's work, let go together before the scenario leaves. - - Leaving waits for work in flight, so the holds are released before the exit is asked for. - """ - - def __init__(self) -> None: - self._holds: List[Hold] = [] - - def add(self, hold: Hold) -> None: - self._holds.append(hold) - - def release_all(self) -> None: - for hold in self._holds: - hold.release() - - -@dataclass(frozen=True) -class ReleaseSignal: - """A file whose appearance lets held work carry on, which a worker process sees as well as the scenario.""" - - path: Path - - def release(self) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.touch() - - def is_released(self) -> bool: - return self.path.exists() - - -class HeldReconstructor: - """A reconstructor that walks a run's stages, stops halfway through matching until released, and builds nothing. - - It decides when the run lands, never what it computes: the walk reports the stages a real - reconstruction reports, and a run left with nothing built writes no file. While held it goes on - announcing the halfway mark, which is where a run the reader stops unwinds, and it waits for as - long as the scenario holds it. - - It travels to a worker with the job, the way the real one does, so it is built from a - configuration and the channels the same way, with the release named beside them. - """ - - def __init__( - self, - config: Config, - channels: AbstractSet[ChannelName], - release_path: Path, - ) -> None: - self.config = config - self.channels = channels - self.release_path = release_path - - def reconstruct( - self, - paths: Sequence[Pathlike], - stems_config: StemsConfig, - *, - report: ReconstructionReporter, - ) -> None: - """Walks the stages of a reconstruction, holding halfway through matching, and answers with nothing built. - - Raises: - OperationCanceled: If the run is withdrawn while it is under way. - """ - del paths, stems_config - for stage in ReconstructionStage: - if stage not in COUNTED_STAGES: - announce(report, stage, STAGE_BEGUN, WHOLE_STAGE) - continue - - for frame in range(FAKE_FRAMES + 1): - announce(report, stage, frame, FAKE_FRAMES) - if stage == ReconstructionStage.MATCHING and frame == HALFWAY: - self._hold(report, stage, frame) - - def _hold( - self, - report: ReconstructionReporter, - stage: ReconstructionStage, - frame: int, - ) -> None: - while not self.release_path.exists(): - announce(report, stage, frame, FAKE_FRAMES) - time.sleep(RELEASE_POLL_SECONDS) - - -class ConversionHold: - """Stands in for the converter's reconstructor with one that holds every run halfway until released.""" - - def __init__(self, signal: ReleaseSignal) -> None: - self._signal = signal - - def install(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - converter_module, - "Reconstructor", - partial(HeldReconstructor, release_path=self._signal.path), - ) - - def release(self) -> None: - self._signal.release() - - -class ScanHold: - """Stands in for the walk a folder scan reads, holding every scan once the tree is read until released. - - The walk meets every entry the folder holds, and then goes on meeting an entry that is no - recording, one every ``interval`` seconds, as a tree of thousands of other files would, until the - scenario lets it end. A reader's Stop is checked between entries, so it is heard while the scan - is held, as soon as the next entry comes. - """ - - def __init__( - self, - signal: ReleaseSignal, - *, - interval: float, - ) -> None: - self._signal = signal - self._interval = interval - - def install(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(scan_module, "walk_entries", self._walk) - - def release(self) -> None: - self._signal.release() - - def _walk(self, root: Path) -> Iterator[Path]: - yield from root.rglob("*") - while not self._signal.is_released(): - yield root / HELD_ENTRY - time.sleep(self._interval) - - -class RegenerationHold: - """Holds every rebuild of an edited channel on its worker before it computes, until the scenario releases it. - - The rebuild waits where the real one runs, so the application draws and answers while the edit - is on its way, and once released the real rebuild computes the edit's result. The hold lets - every rebuild through from its release on, until the scenario holds again. A scenario standing - for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service - reports the way it reports a rebuild that raised. - """ - - def __init__(self) -> None: - self._released = threading.Event() - self._lock = threading.Lock() - self._waiting = 0 - self._failure: Optional[Exception] = None - - def install(self, monkeypatch: pytest.MonkeyPatch) -> None: - rebuild = RegenerationService._run - - def held( - service: RegenerationService, - reconstruction: Reconstruction, - channel_name: ChannelName, - features: Features, - heard: AbstractSet[int], - ) -> None: - self._wait() - if self._failure is not None: - service._emit(ServiceError(exception=self._failure)) - return - - rebuild(service, reconstruction, channel_name, features, heard) - - monkeypatch.setattr(RegenerationService, "_run", held) - - def waiting(self) -> int: - """How many rebuilds stand held.""" - with self._lock: - return self._waiting - - def release(self) -> None: - self._released.set() - - def hold_again(self) -> None: - """Holds every rebuild asked for from now on, as the hold did before its release.""" - self._released.clear() - - def fail(self, failure: Exception) -> None: - """Lets every held rebuild, and every one asked for after, go as ``failure``.""" - self._failure = failure - self._released.set() - - def _wait(self) -> None: - with self._lock: - self._waiting += 1 - - self._released.wait() - with self._lock: - self._waiting -= 1 - - -class ExportHold: - """Holds every export at one report of its stages, until the scenario releases it. - - A run reports each stage it reaches and then asks whether it is still wanted. The hold answers - that question at the report it stands at, once the export's window has heard the stage, so the - window shows the run under way. While held, the run still hears a cancel and unwinds the way a - cancel unwinds a real run. Released, every run goes on to its end until the scenario holds again. - """ - - def __init__(self) -> None: - self._released = threading.Event() - self._lock = threading.Lock() - self._waiting = 0 - self._held_report = FIRST_REPORT - - def install(self, monkeypatch: pytest.MonkeyPatch) -> None: - build = ExportProgressReporter.__init__ - - def held( - reporter: ExportProgressReporter, - emit: Callable[[ServiceProgress[ExportStage]], None], - withdrawn: Callable[[], bool], - ) -> None: - build(reporter, emit, self._gate(withdrawn)) - - monkeypatch.setattr(ExportProgressReporter, "__init__", held) - - def waiting(self) -> int: - """How many runs stand held.""" - with self._lock: - return self._waiting - - def release(self) -> None: - self._released.set() - - def hold_at(self, report: int) -> None: - """Holds every run from now on at its report numbered ``report``, counted from 0, letting the earlier ones pass.""" - self._held_report = report - self._released.clear() - - def _gate(self, withdrawn: Callable[[], bool]) -> Callable[[], bool]: - """The question one run asks after each report, which waits at the held report until released or withdrawn.""" - reports = itertools.count() - - def asked() -> bool: - if next(reports) == self._held_report: - self._wait(withdrawn) - - return withdrawn() - - return asked - - def _wait(self, withdrawn: Callable[[], bool]) -> None: - with self._lock: - self._waiting += 1 - - while not self._released.wait(RELEASE_POLL_SECONDS) and not withdrawn(): - continue - - with self._lock: - self._waiting -= 1 diff --git a/tests/suite/screens/holds/__init__.py b/tests/suite/screens/holds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/holds/base.py b/tests/suite/screens/holds/base.py new file mode 100644 index 000000000..176043c9f --- /dev/null +++ b/tests/suite/screens/holds/base.py @@ -0,0 +1,27 @@ +from typing import List, Protocol + + +class Hold(Protocol): + """Work a scenario keeps under way until it lets go, so a gesture lands while the work runs.""" + + def release(self) -> None: + """Lets the work carry on to its end.""" + + +class Holds: + """Every hold a scenario put on the application's work, let go together before the scenario leaves. + + Leaving waits for work in flight, so the holds are released before the exit is asked for. + """ + + def __init__(self) -> None: + self._holds: List[Hold] = [] + + def add(self, hold: Hold) -> None: + """Puts ``hold`` among the holds released together.""" + self._holds.append(hold) + + def release_all(self) -> None: + """Releases every hold that was added.""" + for hold in self._holds: + hold.release() diff --git a/tests/suite/screens/holds/constants.py b/tests/suite/screens/holds/constants.py new file mode 100644 index 000000000..350e25b0a --- /dev/null +++ b/tests/suite/screens/holds/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +RELEASE_POLL_SECONDS: Final[float] = 0.05 diff --git a/tests/suite/screens/holds/conversion.py b/tests/suite/screens/holds/conversion.py new file mode 100644 index 000000000..6cef58362 --- /dev/null +++ b/tests/suite/screens/holds/conversion.py @@ -0,0 +1,94 @@ +import time +from functools import partial +from pathlib import Path +from typing import AbstractSet, Sequence + +import pytest + +import sampletones_core.reconstructions.converter.converter as converter_module +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.progress import STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, announce +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.types.path import Pathlike +from tests.suite.conversion import COUNTED_STAGES, FAKE_FRAMES, HALFWAY +from tests.suite.screens.holds.constants import RELEASE_POLL_SECONDS +from tests.suite.screens.holds.signal import ReleaseSignal + + +class HeldReconstructor: + """A reconstructor that walks a run's stages, stops halfway through matching until released, and builds + nothing. + + It decides when the run lands, never what it computes: the walk reports the stages a real + reconstruction reports, and a run left with nothing built writes no file. While held it goes on + announcing the halfway mark, which is where a run the reader stops unwinds, and it waits for as + long as the scenario holds it. + + It travels to a worker with the job, the way the real one does, so it is built from a + configuration and the channels the same way, with the release named beside them. + """ + + def __init__( + self, + config: Config, + channels: AbstractSet[ChannelName], + release_path: Path, + ) -> None: + self.config = config + self.channels = channels + self.release_path = release_path + + def reconstruct( + self, + paths: Sequence[Pathlike], + stems_config: StemsConfig, + *, + report: ReconstructionReporter, + ) -> None: + """Walks the stages of a reconstruction, holding halfway through matching, and answers with nothing + built. + + Raises: + OperationCanceled: If the run is withdrawn while it is under way. + """ + del paths, stems_config + for stage in ReconstructionStage: + if stage not in COUNTED_STAGES: + announce(report, stage, STAGE_BEGUN, WHOLE_STAGE) + continue + + for frame in range(FAKE_FRAMES + 1): + announce(report, stage, frame, FAKE_FRAMES) + if stage == ReconstructionStage.MATCHING and frame == HALFWAY: + self._hold(report, stage, frame) + + def _hold( + self, + report: ReconstructionReporter, + stage: ReconstructionStage, + frame: int, + ) -> None: + while not self.release_path.exists(): + announce(report, stage, frame, FAKE_FRAMES) + time.sleep(RELEASE_POLL_SECONDS) + + +class ConversionHold: + """Stands in for the converter's reconstructor with one that holds every run halfway until released.""" + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the converter's reconstructor with one that holds every run halfway through matching.""" + monkeypatch.setattr( + converter_module, + "Reconstructor", + partial(HeldReconstructor, release_path=self._signal.path), + ) + + def release(self) -> None: + """Lets every held run carry on to its end.""" + self._signal.release() diff --git a/tests/suite/screens/holds/export.py b/tests/suite/screens/holds/export.py new file mode 100644 index 000000000..9ab1a4664 --- /dev/null +++ b/tests/suite/screens/holds/export.py @@ -0,0 +1,82 @@ +import itertools +import threading +from typing import Callable, Final + +import pytest + +from sampletones_application.services.export.reporter import ExportProgressReporter +from sampletones_application.services.result import ServiceProgress +from sampletones_core.exports.stage import ExportStage +from tests.suite.screens.holds.constants import RELEASE_POLL_SECONDS + +FIRST_REPORT: Final[int] = 0 + + +class ExportHold: + """Holds every export at one report of its stages, until the scenario releases it. + + A run reports each stage it reaches and then asks whether it is still wanted. The hold answers + that question at the report it stands at, once the export's window has heard the stage, so the + window shows the run under way. While held, the run still hears a cancel and unwinds the way a + cancel unwinds a real run. Released, every run goes on to its end until the scenario holds again. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._held_report = FIRST_REPORT + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Wraps the export reporter so each run asks its question through the hold.""" + build = ExportProgressReporter.__init__ + + def held( + reporter: ExportProgressReporter, + emit: Callable[[ServiceProgress[ExportStage]], None], + withdrawn: Callable[[], bool], + ) -> None: + build(reporter, emit, self._gate(withdrawn)) + + monkeypatch.setattr(ExportProgressReporter, "__init__", held) + + def waiting(self) -> int: + """How many runs stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + """Lets every held run, and every one that starts later, go on to its end.""" + self._released.set() + + def hold_at(self, report: int) -> None: + """Holds every run from now on at its report numbered ``report``, counted from 0. + + The earlier reports pass. + """ + self._held_report = report + self._released.clear() + + def _gate(self, withdrawn: Callable[[], bool]) -> Callable[[], bool]: + """The question one run asks after each report; it waits at the held report until released or + withdrawn. + """ + reports = itertools.count() + + def asked() -> bool: + if next(reports) == self._held_report: + self._wait(withdrawn) + + return withdrawn() + + return asked + + def _wait(self, withdrawn: Callable[[], bool]) -> None: + with self._lock: + self._waiting += 1 + + while not self._released.wait(RELEASE_POLL_SECONDS) and not withdrawn(): + continue + + with self._lock: + self._waiting -= 1 diff --git a/tests/suite/screens/holds/regeneration.py b/tests/suite/screens/holds/regeneration.py new file mode 100644 index 000000000..3fc9acf2d --- /dev/null +++ b/tests/suite/screens/holds/regeneration.py @@ -0,0 +1,74 @@ +import threading +from typing import AbstractSet, Optional + +import pytest + +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.services.result import ServiceError +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +class RegenerationHold: + """Holds every rebuild of an edited channel on its worker before it computes, until the scenario releases + it. + + The rebuild waits where the real one runs, so the application draws and answers while the edit + is on its way, and once released the real rebuild computes the edit's result. The hold lets + every rebuild through from its release on, until the scenario holds again. A scenario standing + for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service + reports the way it reports a rebuild that raised. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._failure: Optional[Exception] = None + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the service's rebuild with one that waits for the release before it computes.""" + rebuild = RegenerationService._run + + def held( + service: RegenerationService, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + self._wait() + if self._failure is not None: + service._emit(ServiceError(exception=self._failure)) + return + + rebuild(service, reconstruction, channel_name, features, heard) + + monkeypatch.setattr(RegenerationService, "_run", held) + + def waiting(self) -> int: + """How many rebuilds stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + """Lets every held rebuild, and every one asked for later, carry on.""" + self._released.set() + + def hold_again(self) -> None: + """Holds every rebuild asked for from now on, as the hold did before its release.""" + self._released.clear() + + def fail(self, failure: Exception) -> None: + """Lets every held rebuild, and every one asked for after, go as ``failure``.""" + self._failure = failure + self._released.set() + + def _wait(self) -> None: + with self._lock: + self._waiting += 1 + + self._released.wait() + with self._lock: + self._waiting -= 1 diff --git a/tests/suite/screens/holds/scan.py b/tests/suite/screens/holds/scan.py new file mode 100644 index 000000000..2366b844c --- /dev/null +++ b/tests/suite/screens/holds/scan.py @@ -0,0 +1,44 @@ +import time +from pathlib import Path +from typing import Final, Iterator + +import pytest + +import sampletones_application.logic.main.sources.scan as scan_module +from tests.suite.screens.holds.signal import ReleaseSignal + +HELD_ENTRY: Final[str] = "held-entry.txt" + + +class ScanHold: + """Stands in for the walk a folder scan reads, holding every scan once the tree is read until released. + + The walk meets every entry the folder holds, and then goes on meeting an entry that is no + recording, one every ``interval`` seconds, as a tree of thousands of other files would, until the + scenario lets it end. A reader's Stop is checked between entries, so it is heard while the scan + is held, as soon as the next entry comes. + """ + + def __init__( + self, + signal: ReleaseSignal, + *, + interval: float, + ) -> None: + self._signal = signal + self._interval = interval + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the folder walk of a scan with the held one.""" + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + """Lets the held walk end.""" + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + """Yields every entry below ``root``, then one more entry every ``interval`` seconds until released.""" + yield from root.rglob("*") + while not self._signal.is_released(): + yield root / HELD_ENTRY + time.sleep(self._interval) diff --git a/tests/suite/screens/holds/signal.py b/tests/suite/screens/holds/signal.py new file mode 100644 index 000000000..710e0e1c1 --- /dev/null +++ b/tests/suite/screens/holds/signal.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ReleaseSignal: + """A file whose appearance lets held work carry on, which a worker process sees as well as the scenario.""" + + path: Path + + def release(self) -> None: + """Creates the file, which lets the held work carry on.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.touch() + + def is_released(self) -> bool: + """Whether the file exists, which is whether the work was let go.""" + return self.path.exists() diff --git a/tests/suite/screens/palettes.py b/tests/suite/screens/palettes.py index 29154c97c..747f6fc6a 100644 --- a/tests/suite/screens/palettes.py +++ b/tests/suite/screens/palettes.py @@ -3,10 +3,9 @@ from sampletones_application.paths import PALETTES_DIRECTORY from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.palette import Palette -from tests.suite.screens.dearpygui.items import rounded_color +from tests.suite.screens.dearpygui.items.colors import rounded_color Color = Tuple[float, ...] - FULL_STEP: Final[float] = 255.0 diff --git a/tests/suite/screens/plugin.py b/tests/suite/screens/plugin.py deleted file mode 100644 index 368891093..000000000 --- a/tests/suite/screens/plugin.py +++ /dev/null @@ -1,323 +0,0 @@ -import os -from pathlib import Path -from typing import Final, Generator, Iterator, Optional - -import pytest - -from sampletones_application.application import Application -from sampletones_application.config.profile import UserProfile -from tests.suite.screens.application import NOTHING_TO_OPEN, Boundaries, ScreenApplication, Startup -from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device -from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs -from tests.suite.screens.boundaries.errors import ErrorRecords -from tests.suite.screens.boundaries.highlights import TableHighlights -from tests.suite.screens.boundaries.reveals import FileManagerStandIn -from tests.suite.screens.boundaries.spawns import SpawnGuard -from tests.suite.screens.dearpygui.bridge import Bridge -from tests.suite.screens.dearpygui.display import VirtualDisplay -from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.hosting import host -from tests.suite.screens.dearpygui.isolation import ReportRecorder, run_isolated -from tests.suite.screens.dearpygui.windows import WindowManager -from tests.suite.screens.dearpygui.xtest import XTestDevice -from tests.suite.screens.environment import ( - ARTIFACTS_VARIABLE, - REPORT_VARIABLE, - SCREEN_SIZE, - ScenarioFolders, - child_environment, - display_backend, -) -from tests.suite.screens.holds import ( - ConversionHold, - ExportHold, - Holds, - RegenerationHold, - ReleaseSignal, - ScanHold, -) -from tests.suite.screens.paths import FAILURE_SCREENSHOT, SCREENS_DIRECTORY -from tests.suite.screens.render_thread import QueueRenderThread -from tests.suite.screens.screen import Screen -from tests.suite.screens.world import World, lived_in_world - -CHILD_TIMEOUT_SECONDS: Final[float] = 180.0 -ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 -JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 -REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" -CONVERSION_RELEASE_FILE: Final[str] = "release-conversion" -SCAN_RELEASE_FILE: Final[str] = "release-scan" -SCAN_ENTRY_SECONDS: Final[float] = 0.05 -STAND_IN_PROGRAMS: Final[str] = "programs" -REVEALS_FILE: Final[str] = "reveals.log" -SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" -DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() -DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() - - -class ScreenScenarioError(AssertionError): - """Raised when a screen scenario is written in a way the tier cannot host.""" - - -def is_screen_item(item: pytest.Item) -> bool: - """Whether ``item`` is a screen scenario, which is any test under ``tests/screens``.""" - return Path(item.path).is_relative_to(SCREENS_DIRECTORY) - - -def is_child_process() -> bool: - """Whether this pytest run is the fresh process one scenario runs in.""" - return REPORT_VARIABLE in os.environ - - -@pytest.hookimpl(tryfirst=True) -def pytest_runtest_protocol( - item: pytest.Item, - nextitem: Optional[pytest.Item], -) -> Optional[bool]: - """Runs a screen scenario in a process of its own and reports what that process reported.""" - del nextitem - if is_child_process() or not is_screen_item(item): - return None - - folders = ScenarioFolders.of(item.nodeid) - folders.prepare() - environment = child_environment( - os.environ, - folders, - display=_worker_display(item.config), - ) - item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) - for report in run_isolated( - item, - environment=environment, - working_directory=folders.home, - report_path=folders.reports, - timeout=CHILD_TIMEOUT_SECONDS, - ): - item.ihook.pytest_runtest_logreport(report=report) - - item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) - return True - - -def pytest_configure(config: pytest.Config) -> None: - if is_child_process(): - recorder = ReportRecorder(config, Path(os.environ[REPORT_VARIABLE])) - config.pluginmanager.register(recorder, REPORT_RECORDER_NAME) - - -def pytest_unconfigure(config: pytest.Config) -> None: - display = config.stash.get(DISPLAY_KEY, None) - if display is not None: - display.stop() - - -@pytest.hookimpl(wrapper=True) -def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None, Optional[object], Optional[object]]: - """Runs a screen scenario's body on a thread of its own while the application draws on this one.""" - if not is_child_process() or not is_screen_item(pyfuncitem): - return (yield) - - loop = pyfuncitem.funcargs.get(SCREEN_APPLICATION_FIXTURE) - if not isinstance(loop, ScreenApplication): - raise ScreenScenarioError(f"A screen scenario takes the screen fixture: {pyfuncitem.nodeid}") - - original = pyfuncitem.obj - - def hosted(*arguments: object, **keywords: object) -> None: - host( - lambda: original(*arguments, **keywords), - loop, - join_timeout=JOIN_TIMEOUT_SECONDS, - ) - - pyfuncitem.obj = hosted - try: - return (yield) - finally: - pyfuncitem.obj = original - - -@pytest.fixture -def world() -> World: - """What the scenario's home holds as the application starts; a scenario overrides it to seed its own.""" - return lived_in_world() - - -@pytest.fixture -def startup() -> Startup: - """The documents the application opens as it starts; a scenario overrides it to open its own.""" - return NOTHING_TO_OPEN - - -@pytest.fixture -def output_device() -> OutputDevice: - """The output the scenario's machine offers; a scenario overrides it to start with none.""" - return OutputDevice.SILENT - - -@pytest.fixture -def screen_render_thread() -> QueueRenderThread: - return QueueRenderThread() - - -@pytest.fixture -def screen_bridge(screen_render_thread: QueueRenderThread) -> Bridge: - return Bridge(screen_render_thread, answer_timeout=ANSWER_TIMEOUT_SECONDS) - - -@pytest.fixture -def screen_holds() -> Holds: - """The work the scenario holds under way, let go before it leaves.""" - return Holds() - - -@pytest.fixture -def conversion_hold( - screen_holds: Holds, - monkeypatch: pytest.MonkeyPatch, -) -> ConversionHold: - """Holds every conversion the scenario starts halfway through matching, until the scenario releases it.""" - hold = ConversionHold(ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / CONVERSION_RELEASE_FILE)) - hold.install(monkeypatch) - screen_holds.add(hold) - return hold - - -@pytest.fixture -def scan_hold( - screen_holds: Holds, - monkeypatch: pytest.MonkeyPatch, -) -> ScanHold: - """Holds every folder scan the scenario starts once its tree is read, until the scenario releases it.""" - hold = ScanHold( - ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / SCAN_RELEASE_FILE), - interval=SCAN_ENTRY_SECONDS, - ) - hold.install(monkeypatch) - screen_holds.add(hold) - return hold - - -@pytest.fixture -def regeneration_hold( - screen_holds: Holds, - monkeypatch: pytest.MonkeyPatch, -) -> RegenerationHold: - """Holds every rebuild an edit of a channel asks for, until the scenario releases it.""" - hold = RegenerationHold() - hold.install(monkeypatch) - screen_holds.add(hold) - return hold - - -@pytest.fixture -def export_hold( - screen_holds: Holds, - monkeypatch: pytest.MonkeyPatch, -) -> ExportHold: - """Holds every export at its first report, until the scenario releases it.""" - hold = ExportHold() - hold.install(monkeypatch) - screen_holds.add(hold) - return hold - - -@pytest.fixture -def screen_boundaries( - output_device: OutputDevice, - screen_holds: Holds, - monkeypatch: pytest.MonkeyPatch, -) -> Boundaries: - """Puts the scenario's stand-ins between the application and the desktop before it starts.""" - dialogs = ScriptedFileDialogs() - dialogs.install(monkeypatch) - errors = ErrorRecords() - errors.install() - artifacts = Path(os.environ[ARTIFACTS_VARIABLE]) - file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE) - file_manager.install(monkeypatch) - spawns = SpawnGuard() - spawns.install() - spawns.allow(file_manager.folder) - highlights = TableHighlights() - highlights.install(monkeypatch) - provide_output_device( - output_device, - home=Path.home(), - monkeypatch=monkeypatch, - ) - return Boundaries( - dialogs=dialogs, - errors=errors, - spawns=spawns, - holds=screen_holds, - file_manager=file_manager, - highlights=highlights, - ) - - -@pytest.fixture -def screen_application( - world: World, - startup: Startup, - screen_render_thread: QueueRenderThread, - screen_bridge: Bridge, - screen_boundaries: Boundaries, -) -> ScreenApplication: - """SampleToNES as ``sampletones open`` starts it, on the scenario's display and in its home.""" - profile = UserProfile.user() - world.write(profile) - application = Application( - profile=profile, - reconstruction_path=startup.reconstruction, - project_path=startup.project, - ) - return ScreenApplication( - application, - render_thread=screen_render_thread, - bridge=screen_bridge, - boundaries=screen_boundaries, - failure_screenshot=Path(os.environ[ARTIFACTS_VARIABLE]) / FAILURE_SCREENSHOT, - state_path=profile.state, - ) - - -@pytest.fixture -def screen( - screen_application: ScreenApplication, - screen_render_thread: QueueRenderThread, - screen_bridge: Bridge, - screen_boundaries: Boundaries, -) -> Iterator[Screen]: - """The application the scenario drives, with a user's hand on its display.""" - device = XTestDevice(os.environ["DISPLAY"]) - window_manager = WindowManager(os.environ["DISPLAY"], os.getpid()) - yield Screen( - bridge=screen_bridge, - render_thread=screen_render_thread, - hand=Hand(screen_bridge, device), - window_manager=window_manager, - language=screen_application.language, - shortcuts=screen_application.shortcuts, - dialogs=screen_boundaries.dialogs, - errors=screen_boundaries.errors, - file_manager=screen_boundaries.file_manager, - highlights=screen_boundaries.highlights, - artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), - ) - window_manager.close() - device.close() - - -def _worker_display(config: pytest.Config) -> str: - """The display this worker's scenarios draw on, started with the first of them.""" - name = config.stash.get(DISPLAY_NAME_KEY, None) - if name is not None: - return name - - display = VirtualDisplay(display_backend(os.environ), SCREEN_SIZE) - name = display.start() - config.stash[DISPLAY_KEY] = display - config.stash[DISPLAY_NAME_KEY] = name - return name diff --git a/tests/suite/screens/plugin/__init__.py b/tests/suite/screens/plugin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/plugin/constants.py b/tests/suite/screens/plugin/constants.py new file mode 100644 index 000000000..9fe0a5c67 --- /dev/null +++ b/tests/suite/screens/plugin/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +import pytest + +from tests.suite.screens.dearpygui.display import VirtualDisplay + +DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() diff --git a/tests/suite/screens/plugin/fixtures.py b/tests/suite/screens/plugin/fixtures.py new file mode 100644 index 000000000..42583d384 --- /dev/null +++ b/tests/suite/screens/plugin/fixtures.py @@ -0,0 +1,169 @@ +import os +from pathlib import Path +from typing import Final, Iterator + +import pytest + +from sampletones_application.application import Application +from sampletones_application.config.profile import UserProfile +from tests.suite.screens.application.boundaries import Boundaries +from tests.suite.screens.application.running import ScreenApplication +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device +from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs +from tests.suite.screens.boundaries.errors import ErrorRecords +from tests.suite.screens.boundaries.highlights import TableHighlights +from tests.suite.screens.boundaries.reveals import FileManagerStandIn +from tests.suite.screens.boundaries.spawns import SpawnGuard +from tests.suite.screens.dearpygui.bridge import Bridge +from tests.suite.screens.dearpygui.display import VirtualDisplay +from tests.suite.screens.dearpygui.hand import Hand +from tests.suite.screens.dearpygui.windows import WindowManager +from tests.suite.screens.dearpygui.xtest import XTestDevice +from tests.suite.screens.environment import ARTIFACTS_VARIABLE, SCREEN_SIZE, display_backend +from tests.suite.screens.holds.base import Holds +from tests.suite.screens.paths import FAILURE_SCREENSHOT +from tests.suite.screens.plugin.constants import DISPLAY_KEY +from tests.suite.screens.plugin.hold_fixtures import screen_holds +from tests.suite.screens.render_thread import QueueRenderThread +from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import lived_in_world + +ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 +STAND_IN_PROGRAMS: Final[str] = "programs" +REVEALS_FILE: Final[str] = "reveals.log" +DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() + + +@pytest.fixture +def world() -> World: + """What the scenario's home holds as the application starts; a scenario overrides it to seed its own.""" + return lived_in_world() + + +@pytest.fixture +def startup() -> Startup: + """The documents the application opens as it starts; a scenario overrides it to open its own.""" + return Startup( + reconstruction=None, + project=None, + ) + + +@pytest.fixture +def output_device() -> OutputDevice: + """The output the scenario's machine offers; a scenario overrides it to start with none.""" + return OutputDevice.SILENT + + +@pytest.fixture +def screen_render_thread() -> QueueRenderThread: + """The queue-backed render thread the scenario's application and bridge share.""" + return QueueRenderThread() + + +@pytest.fixture +def screen_bridge(screen_render_thread: QueueRenderThread) -> Bridge: + """The bridge through which a scenario asks the render thread for readings and gestures.""" + return Bridge(screen_render_thread, answer_timeout=ANSWER_TIMEOUT_SECONDS) + + +@pytest.fixture +def screen_boundaries( + output_device: OutputDevice, + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> Boundaries: + """Puts the scenario's stand-ins between the application and the desktop before it starts.""" + dialogs = ScriptedFileDialogs() + dialogs.install(monkeypatch) + errors = ErrorRecords() + errors.install() + artifacts = Path(os.environ[ARTIFACTS_VARIABLE]) + file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE) + file_manager.install(monkeypatch) + spawns = SpawnGuard() + spawns.install() + spawns.allow(file_manager.folder) + highlights = TableHighlights() + highlights.install(monkeypatch) + provide_output_device( + output_device, + home=Path.home(), + monkeypatch=monkeypatch, + ) + return Boundaries( + dialogs=dialogs, + errors=errors, + spawns=spawns, + holds=screen_holds, + file_manager=file_manager, + highlights=highlights, + ) + + +@pytest.fixture +def screen_application( + world: World, + startup: Startup, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> ScreenApplication: + """SampleToNES as ``sampletones open`` starts it, on the scenario's display and in its home.""" + profile = UserProfile.user() + world.write(profile) + application = Application( + profile=profile, + reconstruction_path=startup.reconstruction, + project_path=startup.project, + ) + return ScreenApplication( + application, + render_thread=screen_render_thread, + bridge=screen_bridge, + boundaries=screen_boundaries, + failure_screenshot=Path(os.environ[ARTIFACTS_VARIABLE]) / FAILURE_SCREENSHOT, + state_path=profile.state, + ) + + +@pytest.fixture +def screen( + screen_application: ScreenApplication, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> Iterator[Screen]: + """The application the scenario drives, with a user's hand on its display.""" + device = XTestDevice(os.environ["DISPLAY"]) + window_manager = WindowManager(os.environ["DISPLAY"], os.getpid()) + yield Screen( + bridge=screen_bridge, + render_thread=screen_render_thread, + hand=Hand(screen_bridge, device), + window_manager=window_manager, + language=screen_application.language, + shortcuts=screen_application.shortcuts, + dialogs=screen_boundaries.dialogs, + errors=screen_boundaries.errors, + file_manager=screen_boundaries.file_manager, + highlights=screen_boundaries.highlights, + artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), + ) + window_manager.close() + device.close() + + +def _worker_display(config: pytest.Config) -> str: + """The display this worker's scenarios draw on, started with the first of them.""" + name = config.stash.get(DISPLAY_NAME_KEY, None) + if name is not None: + return name + + display = VirtualDisplay(display_backend(os.environ), SCREEN_SIZE) + name = display.start() + config.stash[DISPLAY_KEY] = display + config.stash[DISPLAY_NAME_KEY] = name + return name diff --git a/tests/suite/screens/plugin/hold_fixtures.py b/tests/suite/screens/plugin/hold_fixtures.py new file mode 100644 index 000000000..22cadbdf1 --- /dev/null +++ b/tests/suite/screens/plugin/hold_fixtures.py @@ -0,0 +1,74 @@ +import os +from pathlib import Path +from typing import Final + +import pytest + +from tests.suite.screens.environment import ARTIFACTS_VARIABLE +from tests.suite.screens.holds.base import Holds +from tests.suite.screens.holds.conversion import ConversionHold +from tests.suite.screens.holds.export import ExportHold +from tests.suite.screens.holds.regeneration import RegenerationHold +from tests.suite.screens.holds.scan import ScanHold +from tests.suite.screens.holds.signal import ReleaseSignal + +CONVERSION_RELEASE_FILE: Final[str] = "release-conversion" +SCAN_RELEASE_FILE: Final[str] = "release-scan" +SCAN_ENTRY_SECONDS: Final[float] = 0.05 + + +@pytest.fixture +def screen_holds() -> Holds: + """The work the scenario holds under way, let go before it leaves.""" + return Holds() + + +@pytest.fixture +def conversion_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ConversionHold: + """Holds every conversion the scenario starts halfway through matching, until the scenario releases it.""" + hold = ConversionHold(ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / CONVERSION_RELEASE_FILE)) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def scan_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ScanHold: + """Holds every folder scan the scenario starts once its tree is read, until the scenario releases it.""" + hold = ScanHold( + ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / SCAN_RELEASE_FILE), + interval=SCAN_ENTRY_SECONDS, + ) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def regeneration_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> RegenerationHold: + """Holds every rebuild an edit of a channel asks for, until the scenario releases it.""" + hold = RegenerationHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture +def export_hold( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ExportHold: + """Holds every export at its first report, until the scenario releases it.""" + hold = ExportHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold diff --git a/tests/suite/screens/plugin/hooks.py b/tests/suite/screens/plugin/hooks.py new file mode 100644 index 000000000..cd0a2c582 --- /dev/null +++ b/tests/suite/screens/plugin/hooks.py @@ -0,0 +1,103 @@ +import os +from pathlib import Path +from typing import Final, Generator, Optional + +import pytest + +from tests.suite.screens.application.running import ScreenApplication +from tests.suite.screens.dearpygui.hosting import host +from tests.suite.screens.dearpygui.isolation import ReportRecorder, run_isolated +from tests.suite.screens.environment import REPORT_VARIABLE, ScenarioFolders, child_environment +from tests.suite.screens.paths import SCREENS_DIRECTORY +from tests.suite.screens.plugin.constants import DISPLAY_KEY +from tests.suite.screens.plugin.fixtures import _worker_display + +CHILD_TIMEOUT_SECONDS: Final[float] = 180.0 +JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 +REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" +SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" + + +class ScreenScenarioError(AssertionError): + """Raised when a screen scenario is written in a shape the tier cannot host.""" + + +def is_screen_item(item: pytest.Item) -> bool: + """Whether ``item`` is a screen scenario, which is any test under ``tests/screens``.""" + return Path(item.path).is_relative_to(SCREENS_DIRECTORY) + + +def is_child_process() -> bool: + """Whether this pytest run is the fresh process one scenario runs in.""" + return REPORT_VARIABLE in os.environ + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_protocol( + item: pytest.Item, + nextitem: Optional[pytest.Item], +) -> Optional[bool]: + """Runs a screen scenario in a process of its own and reports what that process reported.""" + del nextitem + if is_child_process() or not is_screen_item(item): + return None + + folders = ScenarioFolders.of(item.nodeid) + folders.prepare() + environment = child_environment( + os.environ, + folders, + display=_worker_display(item.config), + ) + item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) + for report in run_isolated( + item, + environment=environment, + working_directory=folders.home, + report_path=folders.reports, + timeout=CHILD_TIMEOUT_SECONDS, + ): + item.ihook.pytest_runtest_logreport(report=report) + + item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) + return True + + +def pytest_configure(config: pytest.Config) -> None: + """In a scenario's own process, registers the recorder that writes its reports to the report file.""" + if is_child_process(): + recorder = ReportRecorder(config, Path(os.environ[REPORT_VARIABLE])) + config.pluginmanager.register(recorder, REPORT_RECORDER_NAME) + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Stops the virtual display this worker started, if it started one.""" + display = config.stash.get(DISPLAY_KEY, None) + if display is not None: + display.stop() + + +@pytest.hookimpl(wrapper=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None, Optional[object], Optional[object]]: + """Runs a screen scenario's body on a thread of its own while the application draws on this one.""" + if not is_child_process() or not is_screen_item(pyfuncitem): + return (yield) + + loop = pyfuncitem.funcargs.get(SCREEN_APPLICATION_FIXTURE) + if not isinstance(loop, ScreenApplication): + raise ScreenScenarioError(f"A screen scenario takes the screen fixture: {pyfuncitem.nodeid}") + + original = pyfuncitem.obj + + def hosted(*arguments: object, **keywords: object) -> None: + host( + lambda: original(*arguments, **keywords), + loop, + join_timeout=JOIN_TIMEOUT_SECONDS, + ) + + pyfuncitem.obj = hosted + try: + return (yield) + finally: + pyfuncitem.obj = original diff --git a/tests/suite/screens/render_thread.py b/tests/suite/screens/render_thread.py index ef678fbdc..f565b7140 100644 --- a/tests/suite/screens/render_thread.py +++ b/tests/suite/screens/render_thread.py @@ -21,9 +21,11 @@ def post( *, frames: int, ) -> None: + """Queues ``task`` to run on the render thread once ``frames`` frames are drawn.""" CallbackQueue.add(task, delay=frames) def is_running(self) -> bool: + """Whether the loop is drawing.""" return self._running.is_set() def start(self) -> None: diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 71b0d5b6f..a46dce0ec 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -27,17 +27,12 @@ from tests.suite.screens.dearpygui.bridge import Bridge, ExpectationError from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import ( - Item, - WindowReading, - read_item_count, - read_label, - read_pointer, - read_theme, - read_viewport_title, - read_visible_text, - read_windows, -) +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import read_item_count +from tests.suite.screens.dearpygui.items.regions import WindowReading, read_windows +from tests.suite.screens.dearpygui.items.texts import read_label, read_visible_text +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.dearpygui.items.viewport import read_pointer, read_viewport_title from tests.suite.screens.dearpygui.recording import FrameRecording from tests.suite.screens.dearpygui.screenshot import capture, drawn_frame from tests.suite.screens.dearpygui.windows import WindowManager @@ -59,7 +54,6 @@ from tests.suite.screens.views.tabs import Tabs ReadingT = TypeVar("ReadingT") - EXPECT_TIMEOUT_SECONDS: Final[float] = 10.0 STEP_SEPARATOR: Final[str] = " โ†’ " SCREENSHOT_SUFFIX: Final[str] = ".png" @@ -261,7 +255,9 @@ def answer_next_save_as( path: Path, type_name: str, ) -> None: - """Answers the next save dialog with ``path``, the offered file type named ``type_name`` picked in it.""" + """Answers the next save dialog with ``path``, the offered file type named ``type_name`` picked in + it. + """ self._dialogs.answer_save_as(path, type_name) def claim_error(self, naming: str) -> None: diff --git a/tests/suite/screens/seeds.py b/tests/suite/screens/seeds.py deleted file mode 100644 index eaf1b035b..000000000 --- a/tests/suite/screens/seeds.py +++ /dev/null @@ -1,492 +0,0 @@ -import shutil -from dataclasses import dataclass -from enum import StrEnum -from pathlib import Path -from typing import Dict, Final, List, Tuple - -import numpy as np -import soundfile - -from sampletones_core.compatibility.kind import ObjectKind -from sampletones_core.configs import Config, InstructionsLibraryConfig -from sampletones_core.constants.enums import ChannelName -from sampletones_core.features.envelope import Envelope -from sampletones_core.fft import Window -from sampletones_core.formats.famitracker.instrument import instrument_to_fti_bytes -from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 -from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence -from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX -from sampletones_core.formats.famitracker.specification.sequences import SequenceKind -from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction -from sampletones_core.library.key import InstructionLibraryKey -from sampletones_core.project import ProjectContainer -from sampletones_core.project.project import Project -from sampletones_core.project.voices.envelopes import InstrumentEnvelopes -from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.sample import Sample -from sampletones_core.reconstructions import Reconstruction -from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming -from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document -from tests.suite.library import build_served_library -from tests.suite.performance import place_instrument -from tests.suite.stems import recorded_from, single_entry_stems_data - -FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" -TRUNCATED_FRACTION: Final[int] = 2 -CONFIG_FIELD: Final[str] = "config" -RECORDING_SAMPLE_RATE: Final[int] = 44100 -RECORDING_AMPLITUDE: Final[float] = 0.5 -STORED_RECORDING_SECONDS: Final[float] = 0.5 -STORED_RECORDING_FREQUENCY: Final[float] = 220.0 -AUDIO_PATH_FIELD: Final[str] = "audio_filepath" -LINE_START_PITCH: Final[int] = 60 -LINE_STEP_FRAMES: Final[int] = 15 -LINE_SPAN: Final[int] = 12 -LINE_VOLUME: Final[int] = 12 -LINE_DUTY_CYCLE: Final[int] = 2 -BASS_PITCH: Final[int] = 45 -BEAT_FRAMES: Final[int] = 15 -BEAT_LENGTH: Final[int] = 3 -BEAT_PERIOD: Final[int] = 4 -BEAT_VOLUME: Final[int] = 10 -ARRANGED_FRAMES: Final[int] = 30 -PAD_VOLUME: Final[Tuple[int, ...]] = (12, 8, 4) -PAD_ARPEGGIO: Final[Tuple[int, ...]] = (0, 0, 0) -FIRST_FRAME: Final[int] = 0 -ONE_FRAME: Final[int] = 1 -FIRST_ROW: Final[int] = 0 -TURNING_PULSE_PITCH: Final[int] = 40 -TURNING_PULSE_SPAN: Final[int] = 24 -TURNING_SECOND_PITCH: Final[int] = 30 -TURNING_SECOND_SPAN: Final[int] = 31 -TURNING_TRIANGLE_PITCH: Final[int] = 30 -TURNING_TRIANGLE_SPAN: Final[int] = 29 -TURNING_PERIODS: Final[int] = 16 -TURNING_LEVELS: Final[int] = 15 -TURNING_DUTY_CYCLES: Final[int] = 4 -TURNING_SECOND_LEVEL_STEP: Final[int] = 7 -TURNING_NOISE_LEVEL_STEP: Final[int] = 5 -TURNING_SECOND_DUTY_TICKS: Final[int] = 3 -LOUDEST_LEVEL: Final[int] = 15 -RELEASING_VOLUME: Final[Tuple[int, ...]] = (15, 8) -RELEASE_POINT: Final[int] = 1 - - -class Damage(StrEnum): - """What went wrong with a stored document before the application met it.""" - - OLDER_VERSION = "older version" - FUTURE_VERSION = "future version" - TRUNCATED = "truncated" - FOREIGN_BYTES = "foreign bytes" - - -@dataclass(frozen=True) -class CopiedFile: - """A file laid in the home as it stands elsewhere.""" - - source: Path - destination: Path - - def write(self) -> None: - self.destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(self.source, self.destination) - - -@dataclass(frozen=True) -class WrittenBytes: - """A file in the home holding exactly ``content``.""" - - destination: Path - content: bytes - - def write(self) -> None: - self.destination.parent.mkdir(parents=True, exist_ok=True) - self.destination.write_bytes(self.content) - - -@dataclass(frozen=True) -class Recording: - """A recording in the home: a sine tone of ``frequency`` hertz lasting ``seconds``.""" - - destination: Path - seconds: float - frequency: float - - def write(self) -> None: - self.destination.parent.mkdir(parents=True, exist_ok=True) - times = np.arange(round(self.seconds * RECORDING_SAMPLE_RATE)) / RECORDING_SAMPLE_RATE - tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * self.frequency * times) - soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE) - - -@dataclass(frozen=True) -class MiniLibrary: - """A small library built for ``config``, saved where the application looks for that configuration's library. - - A conversion matches against it in seconds, and the Instructions tab browses it. - """ - - config: Config - - def write(self) -> None: - library, key = build_served_library(self.config) - library.save_data(key, library.data[key]) - - -@dataclass(frozen=True) -class StoredReconstruction: - """The reconstruction the last release wrote, laid at ``destination`` the way this build writes it. - - It names the recording :func:`stored_recording` lays in the home, so it opens with its source. - """ - - destination: Path - - def write(self) -> None: - self.destination.parent.mkdir(parents=True, exist_ok=True) - _stored_reconstruction().save(self.destination) - - -@dataclass(frozen=True) -class StoredProject: - """A project holding the stored reconstruction as the sample ``sample`` and a hand-written ``instrument``.""" - - destination: Path - sample: str - instrument: str - - def write(self) -> None: - project = Project.create() - project.voices.append(Sample(name=self.sample, reconstruction=_stored_reconstruction())) - project.voices.append(Instrument(name=self.instrument)) - self.destination.parent.mkdir(parents=True, exist_ok=True) - ProjectContainer.save(project, self.destination) - - -@dataclass(frozen=True) -class PlayableReconstruction: - """A reconstruction sounding a rising line on Pulse 1, a bass on the triangle and a beat on the noise. - - Each recording of ``recordings`` plays the ``frames`` frames once, in turn, so the document - lasts as long as the recordings laid at those paths, one :class:`Recording` of ``frames`` frames - each, and opens with its sources. - """ - - destination: Path - recordings: Tuple[Path, ...] - frames: int - - def write(self) -> None: - instructions = _playable_instructions(self.frames) - reconstruction = Reconstruction.create( - instructions=instructions, - config=Config(), - coefficient=1.0, - audio_filepath=self.recordings[:1], - stems_data=single_entry_stems_data(list(instructions), instructions), - ) - if len(self.recordings) > 1: - reconstruction = recorded_from(reconstruction, self.recordings) - - self.destination.parent.mkdir(parents=True, exist_ok=True) - reconstruction.save(self.destination) - - -def _playable_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]: - line: List[InstructionUnion] = [ - PulseInstruction( - on=True, - pitch=LINE_START_PITCH + (frame // LINE_STEP_FRAMES) % LINE_SPAN, - volume=LINE_VOLUME, - duty_cycle=LINE_DUTY_CYCLE, - ) - for frame in range(frames) - ] - bass: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH) for _ in range(frames)] - beat: List[InstructionUnion] = [ - NoiseInstruction( - on=frame % BEAT_FRAMES < BEAT_LENGTH, - period=BEAT_PERIOD, - volume=BEAT_VOLUME if frame % BEAT_FRAMES < BEAT_LENGTH else 0, - short=False, - ) - for frame in range(frames) - ] - return {ChannelName.PULSE1: line, ChannelName.TRIANGLE: bass, ChannelName.NOISE: beat} - - -@dataclass(frozen=True) -class ArrangedProject: - """A project with two samples and a hand-written voice, each placed once on the song's first pattern. - - ``line`` is a sample sounding Pulse 1, the triangle and the noise; ``bass`` a sample sounding the - triangle alone; ``pad`` a hand-written voice fading on Pulse 2, its arpeggio flat. The first starts the pattern on - Pulse 1, the hand-written voice comes in on Pulse 2 at ``pad_row``, and the bass on the triangle at - ``bass_row``. The order plays that pattern ``order_frames`` times. - """ - - destination: Path - line: str - bass: str - pad: str - pad_row: int - bass_row: int - order_frames: int - - def write(self) -> None: - line_instructions = _playable_instructions(ARRANGED_FRAMES) - bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} - project = Project.create() - line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, Config())) - bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, Config())) - pad = Instrument( - name=self.pad, - envelopes=InstrumentEnvelopes( - volume=Envelope[int](items=PAD_VOLUME), - arpeggio=Envelope[int](items=PAD_ARPEGGIO), - ), - ) - for voice in (line, bass, pad): - project.voices.append(voice) - - place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=line) - place_instrument(project, channel_name=ChannelName.PULSE2, row_index=self.pad_row, sample=pad) - place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=self.bass_row, sample=bass) - _repeat_first_frame(project, self.order_frames) - _save_project(project, self.destination) - - -@dataclass(frozen=True) -class OverlongProject: - """A project whose song changes on every channel at every tick, through ``order_frames`` frames. - - Its one sample, ``sample``, plays a new value on each channel at every tick for as long as a - frame lasts, and every frame of the order plays it from the top. Stored tick by tick, the song - outgrows the room an NSF program has; stored with each repeat saved once, it fits. - """ - - destination: Path - sample: str - order_frames: int - - def write(self) -> None: - project = Project.create() - ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(ONE_FRAME) - sample = Sample( - name=self.sample, reconstruction=_detached_reconstruction(_turning_instructions(ticks), Config()) - ) - project.voices.append(sample) - for channel in ChannelName.items(): - place_instrument(project, channel_name=channel, row_index=FIRST_ROW, sample=sample) - - _repeat_first_frame(project, self.order_frames) - _save_project(project, self.destination) - - -@dataclass(frozen=True) -class TwoTuningsProject: - """A project whose sample ``line`` was converted at the default tuning, and ``bass`` with A4 at ``a4_frequency`` hertz. - - The line starts the first pattern on Pulse 1 and the bass the triangle, so the song plays both. - """ - - destination: Path - line: str - bass: str - a4_frequency: float - - def write(self) -> None: - line_instructions = _playable_instructions(ARRANGED_FRAMES) - bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} - default = Config() - retuned = default.model_copy( - update={"library": default.library.model_copy(update={"a4_frequency": self.a4_frequency})} - ) - project = Project.create() - line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, default)) - bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, retuned)) - for voice in (line, bass): - project.voices.append(voice) - - place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=line) - place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=FIRST_ROW, sample=bass) - _save_project(project, self.destination) - - -@dataclass(frozen=True) -class LongEnvelopeProject: - """A project holding two hand-written voices whose volume envelopes run long, both placed on the first pattern. - - ``long`` fades through ``long_items`` items and ``middling`` through ``middling_items``, neither - of them ending in silence. - """ - - destination: Path - long: str - long_items: int - middling: str - middling_items: int - - def write(self) -> None: - project = Project.create() - long = _fading_instrument(self.long, self.long_items) - middling = _fading_instrument(self.middling, self.middling_items) - for voice in (long, middling): - project.voices.append(voice) - - place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=long) - place_instrument(project, channel_name=ChannelName.PULSE2, row_index=FIRST_ROW, sample=middling) - _save_project(project, self.destination) - - -@dataclass(frozen=True) -class ReleasingInstrumentFile: - """A FamiTracker instrument file of the voice ``name``, whose volume sequence states a release point.""" - - destination: Path - name: str - - def write(self) -> None: - sequences = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind} - sequences[SequenceKind.VOLUME] = InstrumentSequence( - kind=SequenceKind.VOLUME, - items=RELEASING_VOLUME, - release_point=RELEASE_POINT, - ) - instrument = Instrument2A03(index=STANDALONE_INSTRUMENT_INDEX, name=self.name, sequences=sequences) - self.destination.parent.mkdir(parents=True, exist_ok=True) - self.destination.write_bytes(instrument_to_fti_bytes(instrument)) - - -def _fading_instrument(name: str, items: int) -> Instrument: - """A hand-written voice whose volume falls from the loudest level to the quietest one before silence, over and over.""" - return Instrument( - name=name, - envelopes=InstrumentEnvelopes( - volume=Envelope[int](items=tuple(LOUDEST_LEVEL - index % TURNING_LEVELS for index in range(items))), - ), - ) - - -def _turning_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]: - """Every channel sounding a new pitch, level or period at each of ``ticks`` ticks.""" - return { - ChannelName.PULSE1: [ - PulseInstruction( - on=True, - pitch=TURNING_PULSE_PITCH + tick % TURNING_PULSE_SPAN, - volume=1 + tick % TURNING_LEVELS, - duty_cycle=tick % TURNING_DUTY_CYCLES, - ) - for tick in range(ticks) - ], - ChannelName.PULSE2: [ - PulseInstruction( - on=True, - pitch=TURNING_SECOND_PITCH + tick % TURNING_SECOND_SPAN, - volume=1 + (tick * TURNING_SECOND_LEVEL_STEP) % TURNING_LEVELS, - duty_cycle=(tick // TURNING_SECOND_DUTY_TICKS) % TURNING_DUTY_CYCLES, - ) - for tick in range(ticks) - ], - ChannelName.TRIANGLE: [ - TriangleInstruction(on=True, pitch=TURNING_TRIANGLE_PITCH + tick % TURNING_TRIANGLE_SPAN) - for tick in range(ticks) - ], - ChannelName.NOISE: [ - NoiseInstruction( - on=True, - period=tick % TURNING_PERIODS, - volume=1 + (tick * TURNING_NOISE_LEVEL_STEP) % TURNING_LEVELS, - short=bool(tick % 2), - ) - for tick in range(ticks) - ], - } - - -def _repeat_first_frame(project: Project, order_frames: int) -> None: - """Lays ``order_frames`` frames in the order, each playing the patterns the first one plays.""" - for _ in range(order_frames - 1): - project.song.duplicate_frame(FIRST_FRAME) - - -def _save_project(project: Project, destination: Path) -> None: - destination.parent.mkdir(parents=True, exist_ok=True) - ProjectContainer.save(project, destination) - - -def _detached_reconstruction( - instructions: Dict[ChannelName, List[InstructionUnion]], - config: Config, -) -> Reconstruction: - """A reconstruction of ``instructions`` naming no recording, the way a project stores a sample.""" - return Reconstruction.create( - instructions=instructions, - config=config, - coefficient=1.0, - audio_filepath=(), - stems_data=single_entry_stems_data(list(instructions), instructions), - ) - - -def stored_recording() -> Recording: - """The recording the archived reconstruction names, laid where its relative path leads from the home. - - The application runs in the home, so a relative path the document names resolves there. - """ - document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) - return Recording( - destination=Path.cwd() / document[AUDIO_PATH_FIELD], - seconds=STORED_RECORDING_SECONDS, - frequency=STORED_RECORDING_FREQUENCY, - ) - - -def _stored_reconstruction() -> Reconstruction: - return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) - - -def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: - """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at ``destination``.""" - return CopiedFile( - source=archived(kind, ARCHIVED_VERSIONS[kind]), - destination=destination, - ) - - -def archived_library(folder: Path) -> CopiedFile: - """The library a release built, laid in ``folder`` under the name its settings give a library file.""" - source = archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY]) - config = Config(library=InstructionsLibraryConfig.model_validate(stored_document(source)[CONFIG_FIELD])) - key = InstructionLibraryKey.create(config.library, Window.from_config(config)) - return CopiedFile(source=source, destination=folder / key.filename) - - -def damaged_document( - kind: ObjectKind, - damage: Damage, - *, - destination: Path, - older_version: str, - future_version: str, -) -> WrittenBytes: - """The archived document of ``kind`` with ``damage`` done to it, laid at ``destination``. - - A document restated at ``older_version`` or ``future_version`` is whole apart from its version, so - a refusal of it is a refusal of that version alone. - """ - source = archived(kind, ARCHIVED_VERSIONS[kind]) - match damage: - case Damage.OLDER_VERSION: - content = restated_document(source, kind, older_version) - case Damage.FUTURE_VERSION: - content = restated_document(source, kind, future_version) - case Damage.TRUNCATED: - whole = source.read_bytes() - content = whole[: len(whole) // TRUNCATED_FRACTION] - case Damage.FOREIGN_BYTES: - content = FOREIGN_BYTES - - return WrittenBytes(destination=destination, content=content) diff --git a/tests/suite/screens/seeds/__init__.py b/tests/suite/screens/seeds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/seeds/archives.py b/tests/suite/screens/seeds/archives.py new file mode 100644 index 000000000..c678a2162 --- /dev/null +++ b/tests/suite/screens/seeds/archives.py @@ -0,0 +1,96 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Final + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document + +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" +TRUNCATED_FRACTION: Final[int] = 2 +CONFIG_FIELD: Final[str] = "config" + + +class Damage(StrEnum): + """What went wrong with a stored document before the application met it.""" + + OLDER_VERSION = "older version" + FUTURE_VERSION = "future version" + TRUNCATED = "truncated" + FOREIGN_BYTES = "foreign bytes" + + +@dataclass(frozen=True) +class CopiedFile: + """A file laid in the home as it stands elsewhere.""" + + source: Path + destination: Path + + def write(self) -> None: + """Copies the source file to the destination, creating its folder.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.source, self.destination) + + +@dataclass(frozen=True) +class WrittenBytes: + """A file in the home holding exactly ``content``.""" + + destination: Path + content: bytes + + def write(self) -> None: + """Writes the content to the destination, creating its folder.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(self.content) + + +def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: + """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at + ``destination``. + """ + return CopiedFile( + source=archived(kind, ARCHIVED_VERSIONS[kind]), + destination=destination, + ) + + +def archived_library(folder: Path) -> CopiedFile: + """The library a release built, laid in ``folder`` under the name its settings give a library file.""" + source = archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY]) + config = Config(library=InstructionsLibraryConfig.model_validate(stored_document(source)[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return CopiedFile(source=source, destination=folder / key.filename) + + +def damaged_document( + kind: ObjectKind, + damage: Damage, + *, + destination: Path, + older_version: str, + future_version: str, +) -> WrittenBytes: + """The archived document of ``kind`` with ``damage`` done to it, laid at ``destination``. + + A document restated at ``older_version`` or ``future_version`` is whole apart from its version, so + a refusal of it is a refusal of that version alone. + """ + source = archived(kind, ARCHIVED_VERSIONS[kind]) + match damage: + case Damage.OLDER_VERSION: + content = restated_document(source, kind, older_version) + case Damage.FUTURE_VERSION: + content = restated_document(source, kind, future_version) + case Damage.TRUNCATED: + whole = source.read_bytes() + content = whole[: len(whole) // TRUNCATED_FRACTION] + case Damage.FOREIGN_BYTES: + content = FOREIGN_BYTES + + return WrittenBytes(destination=destination, content=content) diff --git a/tests/suite/screens/seeds/constants.py b/tests/suite/screens/seeds/constants.py new file mode 100644 index 000000000..c247e5707 --- /dev/null +++ b/tests/suite/screens/seeds/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +TURNING_LEVELS: Final[int] = 15 diff --git a/tests/suite/screens/seeds/libraries.py b/tests/suite/screens/seeds/libraries.py new file mode 100644 index 000000000..3f1aad606 --- /dev/null +++ b/tests/suite/screens/seeds/libraries.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from sampletones_core.configs import Config +from tests.suite.library import build_served_library + + +@dataclass(frozen=True) +class MiniLibrary: + """A small library built for ``config``, saved where the application looks for that configuration's + library. + + A conversion matches against it in seconds, and the Instructions tab browses it. + """ + + config: Config + + def write(self) -> None: + """Builds the library and saves it where the application looks for it.""" + library, key = build_served_library(self.config) + library.save_data(key, library.data[key]) diff --git a/tests/suite/screens/seeds/projects.py b/tests/suite/screens/seeds/projects.py new file mode 100644 index 000000000..ba9067277 --- /dev/null +++ b/tests/suite/screens/seeds/projects.py @@ -0,0 +1,228 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.famitracker.instrument import instrument_to_fti_bytes +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind +from sampletones_core.project import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from tests.suite.performance import place_instrument +from tests.suite.screens.seeds.constants import TURNING_LEVELS +from tests.suite.screens.seeds.reconstructions import ( + _detached_reconstruction, + _playable_instructions, + _stored_reconstruction, + _turning_instructions, +) + +ARRANGED_FRAMES: Final[int] = 30 +PAD_VOLUME: Final[Tuple[int, ...]] = (12, 8, 4) +PAD_ARPEGGIO: Final[Tuple[int, ...]] = (0, 0, 0) +FIRST_FRAME: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +FIRST_ROW: Final[int] = 0 +LOUDEST_LEVEL: Final[int] = 15 +RELEASING_VOLUME: Final[Tuple[int, ...]] = (15, 8) +RELEASE_POINT: Final[int] = 1 + + +@dataclass(frozen=True) +class StoredProject: + """A project holding the stored reconstruction as the sample ``sample`` and a hand-written + ``instrument``. + """ + + destination: Path + sample: str + instrument: str + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + project.voices.append(Sample(name=self.sample, reconstruction=_stored_reconstruction())) + project.voices.append(Instrument(name=self.instrument)) + self.destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, self.destination) + + +@dataclass(frozen=True) +class ArrangedProject: + """A project with two samples and a hand-written voice, each placed once on the song's first pattern. + + ``line`` is a sample sounding Pulse 1, the triangle and the noise; ``bass`` a sample sounding the + triangle alone; ``pad`` a hand-written voice fading on Pulse 2, its arpeggio flat. The line starts + the pattern on Pulse 1, the hand-written voice comes in on Pulse 2 at ``pad_row``, and the bass on + the triangle at ``bass_row``. The order plays that pattern ``order_frames`` times. + """ + + destination: Path + line: str + bass: str + pad: str + pad_row: int + bass_row: int + order_frames: int + + def write(self) -> None: + """Saves the project to the destination.""" + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, Config())) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, Config())) + pad = Instrument( + name=self.pad, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=PAD_VOLUME), + arpeggio=Envelope[int](items=PAD_ARPEGGIO), + ), + ) + for voice in (line, bass, pad): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=line) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=self.pad_row, sample=pad) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=self.bass_row, sample=bass) + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class OverlongProject: + """A project whose song changes on every channel at every tick, through ``order_frames`` frames. + + Its one sample, ``sample``, plays a new value on each channel at every tick for as long as a + frame lasts, and every frame of the order plays it from the top. Stored tick by tick, the song + outgrows the room an NSF program has; stored with each repeat saved once, it fits. + """ + + destination: Path + sample: str + order_frames: int + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(ONE_FRAME) + sample = Sample( + name=self.sample, reconstruction=_detached_reconstruction(_turning_instructions(ticks), Config()) + ) + project.voices.append(sample) + for channel in ChannelName.items(): + place_instrument(project, channel_name=channel, row_index=FIRST_ROW, sample=sample) + + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class TwoTuningsProject: + """A project whose sample ``line`` was converted at the default tuning, and ``bass`` with A4 at + ``a4_frequency`` hertz. + + The line starts the first pattern on Pulse 1 and the bass the triangle, so the song plays both. + """ + + destination: Path + line: str + bass: str + a4_frequency: float + + def write(self) -> None: + """Saves the project to the destination.""" + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + default = Config() + retuned = default.model_copy( + update={"library": default.library.model_copy(update={"a4_frequency": self.a4_frequency})} + ) + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, default)) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, retuned)) + for voice in (line, bass): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=line) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=FIRST_ROW, sample=bass) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class LongEnvelopeProject: + """A project holding two hand-written voices whose volume envelopes run long, both placed on the first + pattern. + + ``long`` fades through ``long_items`` items and ``middling`` through ``middling_items``, neither + of them ending in silence. + """ + + destination: Path + long: str + long_items: int + middling: str + middling_items: int + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + long = _fading_instrument(self.long, self.long_items) + middling = _fading_instrument(self.middling, self.middling_items) + for voice in (long, middling): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=long) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=FIRST_ROW, sample=middling) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class ReleasingInstrumentFile: + """A FamiTracker instrument file of the voice ``name``, whose volume sequence has a release point.""" + + destination: Path + name: str + + def write(self) -> None: + """Writes the instrument file to the destination.""" + sequences = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind} + sequences[SequenceKind.VOLUME] = InstrumentSequence( + kind=SequenceKind.VOLUME, + items=RELEASING_VOLUME, + release_point=RELEASE_POINT, + ) + instrument = Instrument2A03(index=STANDALONE_INSTRUMENT_INDEX, name=self.name, sequences=sequences) + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(instrument_to_fti_bytes(instrument)) + + +def _fading_instrument(name: str, items: int) -> Instrument: + """A hand-written voice whose volume falls from the loudest level through the quieter ones, over and + over. + """ + return Instrument( + name=name, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=tuple(LOUDEST_LEVEL - index % TURNING_LEVELS for index in range(items))), + ), + ) + + +def _repeat_first_frame(project: Project, order_frames: int) -> None: + """Lays ``order_frames`` frames in the order, each playing the patterns the first one plays.""" + for _ in range(order_frames - 1): + project.song.duplicate_frame(FIRST_FRAME) + + +def _save_project(project: Project, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, destination) diff --git a/tests/suite/screens/seeds/reconstructions.py b/tests/suite/screens/seeds/reconstructions.py new file mode 100644 index 000000000..eaeb3270a --- /dev/null +++ b/tests/suite/screens/seeds/reconstructions.py @@ -0,0 +1,159 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Tuple + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions import Reconstruction +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived +from tests.suite.screens.seeds.constants import TURNING_LEVELS +from tests.suite.stems import recorded_from, single_entry_stems_data + +LINE_START_PITCH: Final[int] = 60 +LINE_STEP_FRAMES: Final[int] = 15 +LINE_SPAN: Final[int] = 12 +LINE_VOLUME: Final[int] = 12 +LINE_DUTY_CYCLE: Final[int] = 2 +BASS_PITCH: Final[int] = 45 +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +BEAT_PERIOD: Final[int] = 4 +BEAT_VOLUME: Final[int] = 10 +TURNING_PULSE_PITCH: Final[int] = 40 +TURNING_PULSE_SPAN: Final[int] = 24 +TURNING_SECOND_PITCH: Final[int] = 30 +TURNING_SECOND_SPAN: Final[int] = 31 +TURNING_TRIANGLE_PITCH: Final[int] = 30 +TURNING_TRIANGLE_SPAN: Final[int] = 29 +TURNING_PERIODS: Final[int] = 16 +TURNING_DUTY_CYCLES: Final[int] = 4 +TURNING_SECOND_LEVEL_STEP: Final[int] = 7 +TURNING_NOISE_LEVEL_STEP: Final[int] = 5 +TURNING_SECOND_DUTY_TICKS: Final[int] = 3 + + +@dataclass(frozen=True) +class StoredReconstruction: + """The reconstruction the last release wrote, laid at ``destination`` the way this build writes it. + + It names the recording :func:`stored_recording` lays in the home, so it opens with its source. + """ + + destination: Path + + def write(self) -> None: + """Saves the reconstruction to the destination.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + _stored_reconstruction().save(self.destination) + + +@dataclass(frozen=True) +class PlayableReconstruction: + """A reconstruction sounding a rising line on Pulse 1, a bass on the triangle and a beat on the noise. + + Each recording of ``recordings`` plays the ``frames`` frames once, in turn, so the document + lasts as long as the recordings laid at those paths, one :class:`Recording` of ``frames`` frames + each, and opens with its sources. + """ + + destination: Path + recordings: Tuple[Path, ...] + frames: int + + def write(self) -> None: + """Saves the reconstruction to the destination, naming each recording as a source.""" + instructions = _playable_instructions(self.frames) + reconstruction = Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=self.recordings[:1], + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + if len(self.recordings) > 1: + reconstruction = recorded_from(reconstruction, self.recordings) + + self.destination.parent.mkdir(parents=True, exist_ok=True) + reconstruction.save(self.destination) + + +def _playable_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]: + """The instructions of ``frames`` frames: a rising line, a bass and a beat on three channels.""" + line: List[InstructionUnion] = [ + PulseInstruction( + on=True, + pitch=LINE_START_PITCH + (frame // LINE_STEP_FRAMES) % LINE_SPAN, + volume=LINE_VOLUME, + duty_cycle=LINE_DUTY_CYCLE, + ) + for frame in range(frames) + ] + bass: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH) for _ in range(frames)] + beat: List[InstructionUnion] = [ + NoiseInstruction( + on=frame % BEAT_FRAMES < BEAT_LENGTH, + period=BEAT_PERIOD, + volume=BEAT_VOLUME if frame % BEAT_FRAMES < BEAT_LENGTH else 0, + short=False, + ) + for frame in range(frames) + ] + return {ChannelName.PULSE1: line, ChannelName.TRIANGLE: bass, ChannelName.NOISE: beat} + + +def _turning_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]: + """Every channel sounding a new pitch, level or period at each of ``ticks`` ticks.""" + return { + ChannelName.PULSE1: [ + PulseInstruction( + on=True, + pitch=TURNING_PULSE_PITCH + tick % TURNING_PULSE_SPAN, + volume=1 + tick % TURNING_LEVELS, + duty_cycle=tick % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.PULSE2: [ + PulseInstruction( + on=True, + pitch=TURNING_SECOND_PITCH + tick % TURNING_SECOND_SPAN, + volume=1 + (tick * TURNING_SECOND_LEVEL_STEP) % TURNING_LEVELS, + duty_cycle=(tick // TURNING_SECOND_DUTY_TICKS) % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.TRIANGLE: [ + TriangleInstruction(on=True, pitch=TURNING_TRIANGLE_PITCH + tick % TURNING_TRIANGLE_SPAN) + for tick in range(ticks) + ], + ChannelName.NOISE: [ + NoiseInstruction( + on=True, + period=tick % TURNING_PERIODS, + volume=1 + (tick * TURNING_NOISE_LEVEL_STEP) % TURNING_LEVELS, + short=bool(tick % 2), + ) + for tick in range(ticks) + ], + } + + +def _detached_reconstruction( + instructions: Dict[ChannelName, List[InstructionUnion]], + config: Config, +) -> Reconstruction: + """A reconstruction of ``instructions`` naming no recording, the way a project stores a sample.""" + return Reconstruction.create( + instructions=instructions, + config=config, + coefficient=1.0, + audio_filepath=(), + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + + +def _stored_reconstruction() -> Reconstruction: + """The reconstruction the compatibility corpus keeps for the release that wrote it.""" + return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) diff --git a/tests/suite/screens/seeds/recordings.py b/tests/suite/screens/seeds/recordings.py new file mode 100644 index 000000000..a3deb9d33 --- /dev/null +++ b/tests/suite/screens/seeds/recordings.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import numpy as np +import soundfile + +from sampletones_core.compatibility.kind import ObjectKind +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document + +RECORDING_SAMPLE_RATE: Final[int] = 44100 +RECORDING_AMPLITUDE: Final[float] = 0.5 +STORED_RECORDING_SECONDS: Final[float] = 0.5 +STORED_RECORDING_FREQUENCY: Final[float] = 220.0 +AUDIO_PATH_FIELD: Final[str] = "audio_filepath" + + +@dataclass(frozen=True) +class Recording: + """A recording in the home: a sine tone of ``frequency`` hertz lasting ``seconds``.""" + + destination: Path + seconds: float + frequency: float + + def write(self) -> None: + """Writes the sine tone to the destination as a sound file.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + times = np.arange(round(self.seconds * RECORDING_SAMPLE_RATE)) / RECORDING_SAMPLE_RATE + tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * self.frequency * times) + soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE) + + +def stored_recording() -> Recording: + """The recording the archived reconstruction names, laid where its relative path leads from the home. + + The application runs in the home, so a relative path the document names resolves there. + """ + document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) + return Recording( + destination=Path.cwd() / document[AUDIO_PATH_FIELD], + seconds=STORED_RECORDING_SECONDS, + frequency=STORED_RECORDING_FREQUENCY, + ) diff --git a/tests/suite/screens/steps/exports.py b/tests/suite/screens/steps/exports.py new file mode 100644 index 000000000..6e7e9cf8c --- /dev/null +++ b/tests/suite/screens/steps/exports.py @@ -0,0 +1,11 @@ +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import forgive_the_hover_race + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project left as it was opened, which goes through at once.""" + forgive_the_hover_race(screen) + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() diff --git a/tests/suite/screens/steps/instructions.py b/tests/suite/screens/steps/instructions.py index 9badf784d..a1f2580f5 100644 --- a/tests/suite/screens/steps/instructions.py +++ b/tests/suite/screens/steps/instructions.py @@ -7,7 +7,7 @@ from sampletones_core.fft import Window from sampletones_core.library.key import InstructionLibraryKey from sampletones_shared.paths.user import LIBRARY_DIRECTORY -from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.screen import Screen @@ -34,7 +34,9 @@ def open_library_row(screen: Screen, row: Item) -> None: def load_library(screen: Screen, config: Config) -> Item: - """Brings the Instructions tab forward and opens the row of the library built for ``config``, which loads it.""" + """Brings the Instructions tab forward and opens the row of the library built for ``config``, which loads + it. + """ screen.tabs.bring_to_front(Tab.INSTRUCTIONS) row = library_row(screen, library_path(config)) open_library_row(screen, row) diff --git a/tests/suite/screens/steps/main.py b/tests/suite/screens/steps/main.py index e35afe5fc..6c421de91 100644 --- a/tests/suite/screens/steps/main.py +++ b/tests/suite/screens/steps/main.py @@ -5,7 +5,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_core.constants.enums import ChannelName -from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.screen import Screen RUN_TIMEOUT_SECONDS: Final[float] = 120.0 diff --git a/tests/suite/screens/steps/project.py b/tests/suite/screens/steps/project.py index da78b12f7..4f257c28f 100644 --- a/tests/suite/screens/steps/project.py +++ b/tests/suite/screens/steps/project.py @@ -1,6 +1,7 @@ import operator from pathlib import Path +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.project import ProjectContainer from tests.suite.screens.boundaries.dialogs import DialogKind from tests.suite.screens.screen import Screen @@ -39,3 +40,14 @@ def retitle_project(screen: Screen, title: str) -> None: def saved_project_title(path: Path) -> str: """The title the project file at ``path`` carries.""" return ProjectContainer.load(path).info.title + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, answering Exit to the question about the project, and waits for the application to stop.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() diff --git a/tests/suite/screens/steps/reconstructions.py b/tests/suite/screens/steps/reconstructions.py index 9cc2d79aa..bce6d1f2c 100644 --- a/tests/suite/screens/steps/reconstructions.py +++ b/tests/suite/screens/steps/reconstructions.py @@ -8,9 +8,10 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_shared.constants.symbols import TITLE_SEPARATOR from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.dearpygui.items import Item -from tests.suite.screens.holds import RegenerationHold +from tests.suite.screens.dearpygui.items.types import Item +from tests.suite.screens.holds.regeneration import RegenerationHold from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION APPLICATION_NAME: Final[str] = "global.dialog.title.main_window" UNTITLED: Final[str] = "global.dialog.label.untitled" @@ -33,7 +34,8 @@ def titled(screen: Screen, *documents: str) -> str: def reconstruction_row(screen: Screen, path: Path) -> Item: - """The browser's row of the reconstruction at ``path``, under the heading listing reconstructions by configuration. + """The browser's row of the reconstruction at ``path``, under the heading listing reconstructions by + configuration. The heading is opened where it stands closed, so the row is in reach. """ @@ -72,7 +74,9 @@ def edit_envelope( sequence: str, title: str, ) -> None: - """Types ``sequence`` over a channel's envelope and waits for the edit to land, which marks the title ``title``.""" + """Types ``sequence`` over a channel's envelope and waits for the edit to land, which marks the title + ``title``. + """ screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) screen.reconstructions.instruments.type_envelope(channel, feature, sequence) screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") @@ -160,7 +164,9 @@ def raise_the_first_level( def converted(recording: Path) -> Path: - """The one reconstruction a run wrote for ``recording``, wherever below the reconstructions folder it went.""" + """The one reconstruction a run wrote for ``recording``, wherever below the reconstructions folder it + went. + """ found = list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{recording.stem}{RECONSTRUCTION_SUFFIX}")) assert len(found) == 1, found return found[0] @@ -179,3 +185,8 @@ def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, cha screen.expect(hold.waiting, bool, description="the rebuild held") return sequence + + +def edited_title(screen: Screen) -> str: + """The window title while the open reconstruction carries unsaved changes.""" + return titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) diff --git a/tests/suite/screens/steps/sequencer.py b/tests/suite/screens/steps/sequencer.py index 100c94f43..328249e76 100644 --- a/tests/suite/screens/steps/sequencer.py +++ b/tests/suite/screens/steps/sequencer.py @@ -3,7 +3,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from tests.suite.screens.dearpygui.items import Item +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.screen import Screen EDIT_VOICE: Final[str] = "sequencer.voices.label.context_edit" @@ -35,7 +35,9 @@ def open_voice(screen: Screen, name: str) -> None: def forgive_the_hover_race(screen: Screen) -> None: - """Forgives the error a voice row's hover meets once the list has rebuilt the row, which the ledger records.""" + """Forgives the error a voice row's hover meets once the list has rebuilt the row, which the ledger + records. + """ screen.forgive_known_error(HOVER_RACE) @@ -49,3 +51,9 @@ def leave_letting_the_project_go(screen: Screen) -> None: prompt.confirm() assert screen.wait_for_exit() + + +def on_the_sequencer(screen: Screen) -> None: + """Brings the Sequencer tab to the front and waits until the project's voices are listed.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") diff --git a/tests/suite/screens/views/audio_settings.py b/tests/suite/screens/views/audio_settings.py index 3042d634e..0c40e9f1e 100644 --- a/tests/suite/screens/views/audio_settings.py +++ b/tests/suite/screens/views/audio_settings.py @@ -10,7 +10,7 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.dearpygui.items.reading import read_item, read_value from tests.suite.screens.dearpygui.reach import UnreachableError COLOR: Final[str] = "color" @@ -18,7 +18,9 @@ class AudioSettings: - """The Audio settings dialog, as far as its master gain: the slider and the line reading its level in decibels.""" + """The Audio settings dialog, as far as its master gain: the slider and the line reading its level in + decibels. + """ def __init__( self, @@ -29,9 +31,11 @@ def __init__( self._hand = hand def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_WINDOW)).shown def gain(self) -> float: + """The master gain the slider stands at.""" return float(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN))) def decibels(self) -> str: @@ -48,7 +52,13 @@ def decibels_color(self) -> Tuple[float, ...]: ) def drag_gain(self, start: float, end: float) -> None: - """Drags the slider from ``start`` to ``end``, each a fraction of its width, past its ends where beyond them.""" + """Drags the slider from ``start`` to ``end``, each a fraction of its width. + + A fraction beyond 0 or 1 drags past the end of the slider. + + Raises: + UnreachableError: If the slider reports no box. + """ box = self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN).rect) if box is None: raise UnreachableError("The master gain slider reports no box") diff --git a/tests/suite/screens/views/bar_graph.py b/tests/suite/screens/views/bar_graph.py index a2ab56325..f227f4e6c 100644 --- a/tests/suite/screens/views/bar_graph.py +++ b/tests/suite/screens/views/bar_graph.py @@ -6,7 +6,7 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.items.reading import read_item from tests.suite.screens.dearpygui.plots import settled_plot_pointer NEAR: Final[float] = 0.25 diff --git a/tests/suite/screens/views/browsers.py b/tests/suite/screens/views/browsers.py index 91179c184..207572592 100644 --- a/tests/suite/screens/views/browsers.py +++ b/tests/suite/screens/views/browsers.py @@ -7,7 +7,9 @@ from sampletones_core.structures.tree.node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import TREE_NODE_TYPE, Item, find_item, read_label +from tests.suite.screens.dearpygui.items.reading import find_item +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import TREE_NODE_TYPE, Item from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_CTRL NodeTest = Callable[[TreeNode], bool] @@ -35,7 +37,9 @@ def file_row(self, path: Path) -> Optional[Item]: return self._row(lambda node: isinstance(node, FileSystemNode) and node.filepath == path) def file_rows(self, path: Path) -> List[Item]: - """Every row naming the file at ``path``, which a browser listing it under several headings draws again.""" + """Every row naming the file at ``path``, which a browser listing it under several headings draws + again. + """ def rows() -> List[Item]: found: List[Item] = [] @@ -62,6 +66,7 @@ def heading(self, label: str) -> Optional[Item]: ) def label(self, row: Item) -> str: + """The words ``row`` reads.""" return self._bridge.ask(lambda: read_label(row)) def is_open(self, row: Item) -> bool: diff --git a/tests/suite/screens/views/context_menu.py b/tests/suite/screens/views/context_menu.py index 90170bb7e..71fefc79b 100644 --- a/tests/suite/screens/views/context_menu.py +++ b/tests/suite/screens/views/context_menu.py @@ -7,16 +7,9 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point, Rect from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import ( - MENU_ITEM_TYPE, - MENU_TYPE, - EntryReading, - Item, - find_labelled, - read_item, - read_label, - read_popup_entries, -) +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.texts import EntryReading, find_labelled, read_label, read_popup_entries +from tests.suite.screens.dearpygui.items.types import MENU_ITEM_TYPE, MENU_TYPE, Item from tests.suite.screens.dearpygui.semantic import invoke ENTRY_INSET: Final[Point] = Point(x=12, y=8) @@ -26,7 +19,7 @@ class MissingEntryError(AssertionError): - """Raised when a scenario chooses an entry the open menu does not offer.""" + """Raised when a scenario chooses an entry that the open menu lacks.""" class ContextMenu: @@ -46,6 +39,7 @@ def __init__( self._hand = hand def is_shown(self) -> bool: + """Whether the menu stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).shown def box(self) -> Rect: @@ -57,9 +51,11 @@ def box(self) -> Rect: return rect def entries(self) -> List[EntryReading]: + """The entries of the menu, each with its label and where it stands, in the order shown.""" return list(self._bridge.ask(lambda: read_popup_entries(TAG_GLOBAL_CONTEXT_WINDOW))) def labels(self) -> List[str]: + """The labels of the entries of the menu, in the order shown.""" return [entry.label for entry in self.entries()] def submenus(self) -> List[str]: @@ -103,7 +99,8 @@ def choose_in( menu_label: str, entry_label: str, ) -> None: - """Opens the submenu reading ``menu_label`` with a click on it, and chooses its entry reading ``entry_label``. + """Opens the submenu reading ``menu_label`` with a click on it, and chooses its entry reading + ``entry_label``. DearPyGui reports where a submenu's entries stand inside a window of its own that it names nowhere, so the entry is chosen by running its callback the way a click does. The menu is then @@ -155,7 +152,10 @@ def _submenu_entry( menu_label: str, entry_label: str, ) -> Item: - """The entry reading ``entry_label`` of the submenu reading ``menu_label``. Runs on the render thread.""" + """The entry reading ``entry_label`` of the submenu reading ``menu_label``. + + Runs on the render thread. + """ menu = find_labelled(TAG_GLOBAL_CONTEXT_WINDOW, menu_label, item_type=MENU_TYPE) return find_labelled(menu, entry_label, item_type=MENU_ITEM_TYPE) diff --git a/tests/suite/screens/views/converter.py b/tests/suite/screens/views/converter.py index e08a01c52..c0a06172c 100644 --- a/tests/suite/screens/views/converter.py +++ b/tests/suite/screens/views/converter.py @@ -32,7 +32,10 @@ from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, find_item, read_item, read_label, read_texts, read_theme +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import find_item, read_item +from tests.suite.screens.dearpygui.items.texts import read_label, read_texts +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.views.notices import Notice from tests.suite.screens.views.prompts import Prompt @@ -66,18 +69,23 @@ def row(self, path: Path) -> str: return self.tags.row(str(path), ROW_TEXT) def has_row(self, path: Path) -> bool: + """Whether a row gathered from ``path`` is drawn.""" return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.row(path)))) def label(self, path: Path) -> str: + """The words the row of ``path`` reads.""" return self._bridge.ask(lambda: read_label(self.row(path))) def is_picked(self, path: Path) -> bool: + """Whether the row of ``path`` stands picked.""" return bool(self._bridge.ask(lambda: dpg.get_value(self.row(path)))) def row_theme(self, path: Path) -> Optional[str]: + """The theme the row of ``path`` wears, or None when it wears none.""" return self._bridge.ask(lambda: read_theme(self.row(path))) def band_theme(self, path: Path) -> Optional[str]: + """The theme the band behind the row of ``path`` wears, or None when it wears none.""" return self._bridge.ask(lambda: read_theme(self.tags.row(str(path), ROW_GROUP))) def rows(self) -> List[str]: @@ -91,18 +99,23 @@ def read() -> List[str]: return self._bridge.ask(read) def row_count(self) -> int: + """How many rows are drawn, folders included.""" return len(self.rows()) def channel_box(self, path: Path, channel: ChannelName) -> str: + """The name of the box ticking ``channel`` on the row of ``path``.""" return self.tags.channel(str(path), channel) def channel_ticked(self, path: Path, channel: ChannelName) -> bool: + """Whether ``channel``'s box on the row of ``path`` stands ticked.""" return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(path, channel)))) def channel_theme(self, path: Path, channel: ChannelName) -> Optional[str]: + """The theme ``channel``'s box on the row of ``path`` wears, or None when it wears none.""" return self._bridge.ask(lambda: read_theme(self.channel_box(path, channel))) def twisty(self, path: Path) -> str: + """The name of the marker that opens the folder gathered from ``path``.""" return self.tags.row(str(path), ROW_TWISTY) def is_open(self, path: Path) -> bool: @@ -110,6 +123,7 @@ def is_open(self, path: Path) -> bool: return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.region(str(path))))) def remove_button(self, path: Path) -> str: + """The name of the button removing the row of ``path``.""" return self.tags.row(str(path), ROW_REMOVE) def has_levels(self) -> bool: @@ -117,9 +131,11 @@ def has_levels(self) -> bool: return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.level(FIRST_LEVEL, LEVEL_STRIP)))) def hint_shown(self) -> bool: + """Whether the hint asking for recordings stands on the card.""" return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT)).shown def list_shown(self) -> bool: + """Whether the list of rows stands on the card.""" return self._bridge.ask(lambda: read_item(self.tags.well)).shown def pick(self, path: Path) -> None: @@ -156,43 +172,57 @@ def __init__( self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) def is_shown(self) -> bool: + """Whether the question stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION)).shown def box(self, path: Path) -> str: + """The name of the box ticking the recording at ``path``.""" return self.tags.row(str(path), SUF_CHECKBOX) def name(self, path: Path) -> str: + """The name of the line showing the recording at ``path``.""" return self.tags.row(str(path), ROW_TEXT) def is_ticked(self, path: Path) -> bool: + """Whether the box of the recording at ``path`` stands ticked.""" return bool(self._bridge.ask(lambda: dpg.get_value(self.box(path)))) def is_live(self, path: Path) -> bool: - """Whether the box answers a click, which a box past a full pick does not.""" + """Whether the box answers a click, which a box beyond a full pick stops doing.""" return self._bridge.ask(lambda: read_item(self.box(path))).enabled def is_highlighted(self, path: Path) -> bool: + """Whether the line of the recording at ``path`` stands highlighted.""" return bool(self._bridge.ask(lambda: dpg.get_value(self.name(path)))) def count_line(self) -> str: + """What the line counting the recordings picked reads.""" return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT))) def can_add(self) -> bool: + """Whether Add answers a click.""" return self._bridge.ask(lambda: read_item(_add_button())).enabled def tick(self, path: Path) -> None: + """Brings the box of the recording at ``path`` into view and clicks it.""" self._hand.scroll_into_view(self.box(path)) self._hand.click(self.box(path)) def add(self) -> None: + """Clicks Add, which takes the recordings ticked.""" self._hand.click(_add_button()) def cancel(self) -> None: + """Clicks Cancel, which closes the question and takes no recording.""" self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, SUF_BUTTON)) class Converter: - """The Converter card: the list, the button naming the run, and the run's progress and questions.""" + """The Converter card: the list, the button naming the run, and the run's progress and questions. + + A scenario reaches the list, the mix question, the prompts and the notices through the attributes + of the card. + """ def __init__( self, @@ -219,6 +249,7 @@ def action(self) -> str: return self._bridge.ask(lambda: read_label(_action_button())) def press_action(self) -> None: + """Brings the button under the list into view and clicks it.""" self._hand.scroll_into_view(_action_button()) self._hand.click(_action_button()) @@ -227,9 +258,11 @@ def run_shown(self) -> bool: return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP)).shown def status(self) -> str: + """What the status line of the run reads.""" return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STATUS))) def progress(self) -> float: + """How far the run has come, as a fraction.""" return float(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_PROGRESS))) def destination(self) -> str: @@ -242,9 +275,11 @@ def scan_shown(self) -> bool: return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_SCAN)).shown def scan_words(self) -> str: + """What the window reading a folder says it is reading.""" return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER))) def stop_scan(self) -> None: + """Clicks Stop on the window reading a folder.""" self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON)) diff --git a/tests/suite/screens/views/display_settings.py b/tests/suite/screens/views/display_settings.py index 54c7dcb75..23df1c3a1 100644 --- a/tests/suite/screens/views/display_settings.py +++ b/tests/suite/screens/views/display_settings.py @@ -21,7 +21,7 @@ from sampletones_application.utils.callbacks.queue import CallbackQueue from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.dearpygui.items.reading import read_item, read_value from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt @@ -48,6 +48,7 @@ def open(self) -> None: self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS) def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW)).shown def resolution(self) -> str: @@ -64,8 +65,9 @@ def resolutions(self) -> Tuple[str, ...]: ) def choose_resolution(self, label: str) -> None: - """Picks the window size reading ``label`` in the Resolution list, which resizes the window and starts the - countdown. + """Picks the window size reading ``label`` in the Resolution list. + + The window resizes and the countdown starts. """ self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, label, CallbackQueue.run)) @@ -82,6 +84,7 @@ def vsync(self) -> bool: return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC))) def toggle_vsync(self) -> None: + """Clicks the vertical sync box, which ticks it or lets it go.""" self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) def borderless(self) -> bool: @@ -105,7 +108,9 @@ def revert(self) -> None: self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, SUF_BUTTON)) def cancel(self) -> None: + """Presses Cancel, which drops the changes and closes the dialog.""" self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, SUF_BUTTON)) def confirm(self) -> None: + """Presses OK, which keeps the changes and closes the dialog.""" self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_OK, SUF_BUTTON)) diff --git a/tests/suite/screens/views/exports.py b/tests/suite/screens/views/exports.py index 770dcddff..1edf75a66 100644 --- a/tests/suite/screens/views/exports.py +++ b/tests/suite/screens/views/exports.py @@ -26,7 +26,8 @@ from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_shown_texts, read_value +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import read_shown_texts from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.notices import Notice @@ -48,6 +49,7 @@ def __init__( self._hand = hand def is_shown(self) -> bool: + """Whether the window stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_SETTINGS_EXPORT_WINDOW)).shown def stages(self) -> Tuple[str, ...]: @@ -55,11 +57,14 @@ def stages(self) -> Tuple[str, ...]: return self._bridge.ask(lambda: read_shown_texts(TAG_SETTINGS_EXPORT_GROUP_STAGES)) def cancel(self) -> None: + """Clicks Cancel, which asks the run to stop.""" self._hand.click(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON)) class NSFWindow: - """The window setting an NSF program up: its channels, its repeat, its level of compression, its title and file.""" + """The window setting an NSF program up: its channels, its repeat, its level of compression, + its title and file. + """ def __init__( self, @@ -70,6 +75,7 @@ def __init__( self._hand = hand def is_shown(self) -> bool: + """Whether the window stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_WINDOW)).shown def offers(self, channel: ChannelName) -> bool: @@ -77,6 +83,7 @@ def offers(self, channel: ChannelName) -> bool: return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled def is_ticked(self, channel: ChannelName) -> bool: + """Whether ``channel``'s box stands ticked.""" return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) def tick(self, channel: ChannelName) -> None: @@ -96,6 +103,7 @@ def says_a_channel_is_needed(self) -> bool: return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)).shown def can_export(self) -> bool: + """Whether Export answers a press.""" return self._bridge.ask(lambda: read_item(_export_button())).enabled def press_export(self) -> None: @@ -107,6 +115,7 @@ def press_export(self) -> None: self._hand.click_at(rect.center) def export(self) -> None: + """Clicks Export, which writes the program to the file chosen.""" self._hand.click(_export_button()) def browse(self) -> None: @@ -114,6 +123,7 @@ def browse(self) -> None: self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_BROWSE, SUF_BUTTON)) def cancel(self) -> None: + """Clicks Cancel, which closes the window.""" self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_CANCEL, SUF_BUTTON)) def repeat(self) -> str: @@ -133,6 +143,7 @@ def choose_level(self, label: str) -> None: self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_SCHEME, label, CallbackQueue.run)) def loop_frame(self) -> str: + """What the Frame field reads, in hexadecimal.""" return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME))) def type_loop_frame(self, frame: str) -> None: @@ -140,6 +151,7 @@ def type_loop_frame(self, frame: str) -> None: self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, frame) def title(self) -> str: + """What the Title field reads.""" return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_TITLE))) def type_title(self, title: str) -> None: @@ -148,11 +160,11 @@ def type_title(self, title: str) -> None: class Exports: - """The ways a project, a reconstruction or an instrument leaves the application as a file, and what they report. + """The ways a project, a reconstruction or an instrument leaves the application as a file. - A project is exported from File โ–ธ Export, a reconstruction's channels from Reconstruction โ–ธ - Export instruments. An export runs under its window, and its outcome stands in a notice: the - project's names the format it was written in, and an instrument's names the file it wrote. + A project is exported from File โ–ธ Export, a reconstruction's channels from Reconstruction โ–ธ Export + instruments. An export runs under its window, and its outcome stands in a notice: the project's names the + format it was written in, and an instrument's names the file it wrote. """ def __init__( diff --git a/tests/suite/screens/views/history.py b/tests/suite/screens/views/history.py index 2ee3b0290..564fd6326 100644 --- a/tests/suite/screens/views/history.py +++ b/tests/suite/screens/views/history.py @@ -12,7 +12,7 @@ ) from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import TEXT_TYPE, Item +from tests.suite.screens.dearpygui.items.types import TEXT_TYPE, Item TABLE_TYPE: Final[str] = "mvAppItemType::mvTable" ENTRY_GROUP: Final[int] = 1 @@ -36,6 +36,7 @@ class HistoryLine: @property def words(self) -> str: + """The words of every piece of the line, joined by spaces.""" return " ".join(segment.words for segment in self.segments) @@ -59,9 +60,11 @@ def current(self) -> HistoryLine: return next(line for line in self.lines() if line.current) def undo(self) -> None: + """Clicks Undo below the list, which steps the project back one entry.""" self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_UNDO, SUF_BUTTON)) def redo(self) -> None: + """Clicks Redo below the list, which steps the project forward one entry.""" self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_REDO, SUF_BUTTON)) @@ -84,6 +87,7 @@ def read_history() -> Tuple[HistoryLine, ...]: def _segments(group: Item) -> Tuple[HistorySegment, ...]: + """The pieces of one history line, each with its words and color, in the order drawn.""" return tuple( HistorySegment( words=str(dpg.get_value(text)), diff --git a/tests/suite/screens/views/instructions.py b/tests/suite/screens/views/instructions.py index 0e9de8709..ae6f350e2 100644 --- a/tests/suite/screens/views/instructions.py +++ b/tests/suite/screens/views/instructions.py @@ -18,7 +18,9 @@ ) from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, read_label, read_value +from tests.suite.screens.dearpygui.items.reading import read_value +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.notices import Notice from tests.suite.screens.views.prompts import Prompt @@ -54,10 +56,12 @@ def generate_label(self) -> str: return self._bridge.ask(lambda: read_label(GENERATE_BUTTON)) def generate(self) -> None: + """Clicks the button generating a library, scrolling it into view first.""" self._hand.scroll_into_view(GENERATE_BUTTON) self._hand.click(GENERATE_BUTTON) def cancel_generation(self) -> None: + """Clicks the button that cancels a generation under way.""" self._hand.click(CANCEL_GENERATION_BUTTON) def status(self) -> str: diff --git a/tests/suite/screens/views/instruments.py b/tests/suite/screens/views/instruments.py index 15e701993..a48d5a8ac 100644 --- a/tests/suite/screens/views/instruments.py +++ b/tests/suite/screens/views/instruments.py @@ -14,7 +14,9 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_label, read_selected_tab, read_theme, read_value +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import read_item, read_selected_tab, read_value +from tests.suite.screens.dearpygui.items.texts import read_label from tests.suite.screens.dearpygui.keys import IMGUI_ENTER from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.bar_graph import BarGraph @@ -42,10 +44,13 @@ def front(self) -> str: return self._bridge.ask(lambda: read_selected_tab(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR)) def tab_label(self, channel: ChannelName) -> str: - """What ``channel``'s tab reads, which names the open hand-written voice on the tab it is edited under.""" + """What ``channel``'s tab reads, which names the open hand-written voice on the tab it is edited + under. + """ return self._bridge.ask(lambda: read_label(tab(channel))) def tab_shown(self, channel: ChannelName) -> bool: + """Whether ``channel``'s tab stands on the screen.""" return self._bridge.ask(lambda: read_item(tab(channel))).shown def tab_theme(self, channel: ChannelName) -> Optional[str]: @@ -69,6 +74,7 @@ def read() -> Tuple[FeatureKey, ...]: return self._bridge.ask(read) def field(self, channel: ChannelName, feature: FeatureKey) -> str: + """The name of the field holding the sequence of ``channel``'s ``feature`` envelope.""" return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH_RAW_DATA, SUF_TEXT) def envelope(self, channel: ChannelName, feature: FeatureKey) -> str: @@ -80,7 +86,9 @@ def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str return self._bridge.ask(lambda: read_theme(self.field(channel, feature))) def hover_field(self, channel: ChannelName, feature: FeatureKey) -> None: - """Rests the pointer on ``channel``'s ``feature`` field, which puts what the field takes on the status bar.""" + """Rests the pointer on ``channel``'s ``feature`` field, which puts what the field takes on the + status bar. + """ field = self.field(channel, feature) self._hand.scroll_into_view(field) rect = self._bridge.ask(lambda: read_item(field).rect) @@ -90,7 +98,9 @@ def hover_field(self, channel: ChannelName, feature: FeatureKey) -> None: self._hand.move_to(rect.center) def type_envelope(self, channel: ChannelName, feature: FeatureKey, sequence: str) -> None: - """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses Enter.""" + """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses + Enter. + """ self.bring_forward(channel) field = self.field(channel, feature) self._hand.scroll_into_view(field) @@ -98,6 +108,7 @@ def type_envelope(self, channel: ChannelName, feature: FeatureKey, sequence: str self._hand.press_key(IMGUI_ENTER, modifiers=[]) def graph(self, channel: ChannelName, feature: FeatureKey) -> BarGraph: + """The graph drawn for ``channel``'s ``feature`` envelope.""" return BarGraph( self._bridge, self._hand, compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GRAPH) ) @@ -116,6 +127,7 @@ def audition(self) -> str: return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION))) def choose_audition(self, label: str) -> None: + """Picks the generator reading ``label`` in the Audition list.""" self._bridge.ask(lambda: choose(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, label, CallbackQueue.run)) def can_export(self, channel: ChannelName) -> bool: @@ -123,15 +135,18 @@ def can_export(self, channel: ChannelName) -> bool: return self._bridge.ask(lambda: read_item(export_button(channel))).enabled def export(self, channel: ChannelName) -> None: + """Clicks ``channel``'s Export instrument button.""" self._hand.scroll_into_view(export_button(channel)) self._hand.click(export_button(channel)) def tab(channel: ChannelName) -> str: + """The name of the tab of ``channel``.""" return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel) def export_button(channel: ChannelName) -> str: + """The name of the Export instrument button of ``channel``.""" return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab(channel), SUF_BUTTON) diff --git a/tests/suite/screens/views/keyboard_settings.py b/tests/suite/screens/views/keyboard_settings.py index 0ed9f2865..077fa013d 100644 --- a/tests/suite/screens/views/keyboard_settings.py +++ b/tests/suite/screens/views/keyboard_settings.py @@ -12,7 +12,8 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_label +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.texts import read_label from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt @@ -36,6 +37,7 @@ def open(self) -> None: self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS) def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_SETTINGS_KEYBINDINGS_WINDOW)).shown def listen_for(self, shortcut_id: ShortcutId) -> None: @@ -49,6 +51,7 @@ def keys_of(self, shortcut_id: ShortcutId) -> str: return self._bridge.ask(lambda: read_label(_shortcut_cell(shortcut_id))) def cancel(self) -> None: + """Presses Cancel, which drops the keys edited and closes the dialog.""" self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, SUF_BUTTON)) def confirm(self) -> None: diff --git a/tests/suite/screens/views/main.py b/tests/suite/screens/views/main.py index 6731d945d..644f42683 100644 --- a/tests/suite/screens/views/main.py +++ b/tests/suite/screens/views/main.py @@ -22,7 +22,9 @@ from sampletones_application.view_model.main.source import CHANNEL_CAP_STEPS from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_texts, read_theme, read_value +from tests.suite.screens.dearpygui.items.colors import read_theme +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import read_texts from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.converter import Converter from tests.suite.screens.views.source import SourceSettings @@ -47,6 +49,7 @@ def __init__( self.tag = tag def is_shown(self) -> bool: + """Whether the card stands on the screen.""" return self._bridge.ask(lambda: read_item(self.tag)).shown def is_collapsed(self) -> bool: @@ -60,7 +63,7 @@ def toggle(self) -> None: self._hand.click(strip) def folds_sideways(self) -> bool: - """Whether the card folds into a rail at its side, rather than into its bar.""" + """Whether the card folds into a rail at its side, and not into its bar.""" return self._bridge.ask(lambda: read_item(self.rail)).exists @property @@ -95,6 +98,7 @@ def __init__( self.source = SourceSettings(bridge, hand) def card(self, tag: str) -> Card: + """A view of the card standing under ``tag``.""" return Card(self._bridge, self._hand, tag) def output(self) -> OutputKind: diff --git a/tests/suite/screens/views/menus.py b/tests/suite/screens/views/menus.py index 89e2beb95..961aecaf3 100644 --- a/tests/suite/screens/views/menus.py +++ b/tests/suite/screens/views/menus.py @@ -11,16 +11,10 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import ( - MENU_ITEM_TYPE, - MENU_TYPE, - Item, - find_labelled, - read_item, - read_label, - read_value, - read_viewport, -) +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import find_labelled, read_label +from tests.suite.screens.dearpygui.items.types import MENU_ITEM_TYPE, MENU_TYPE, Item +from tests.suite.screens.dearpygui.items.viewport import read_viewport from tests.suite.screens.dearpygui.semantic import invoke HEADER_INSET: Final[Point] = Point(x=12, y=8) diff --git a/tests/suite/screens/views/notices.py b/tests/suite/screens/views/notices.py index 9aef829bc..332cad02b 100644 --- a/tests/suite/screens/views/notices.py +++ b/tests/suite/screens/views/notices.py @@ -19,6 +19,7 @@ def __init__( self.prompt = Prompt(bridge, hand, tag) def is_shown(self) -> bool: + """Whether a notice of this kind stands on the screen.""" return self.prompt.is_shown() def words(self) -> str: diff --git a/tests/suite/screens/views/project.py b/tests/suite/screens/views/project.py index 6a7b77b86..0c4f9b029 100644 --- a/tests/suite/screens/views/project.py +++ b/tests/suite/screens/views/project.py @@ -15,7 +15,7 @@ ) from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_value +from tests.suite.screens.dearpygui.items.reading import read_item, read_value from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt @@ -38,9 +38,11 @@ def open(self) -> None: self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES) def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" return self._bridge.ask(lambda: read_item(TAG_SETTINGS_PROPERTIES_WINDOW)).shown def title(self) -> str: + """The title the title field holds.""" return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE))) def retitle(self, title: str) -> None: @@ -53,6 +55,7 @@ def retype_highlights(self, first: int, second: int) -> None: self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, str(second)) def confirm(self) -> None: + """Presses OK, which keeps the typed values and closes the dialog.""" self._hand.click(compose_tag(TAG_SETTINGS_PROPERTIES_BUTTON_OK, SUF_BUTTON)) diff --git a/tests/suite/screens/views/prompts.py b/tests/suite/screens/views/prompts.py index b2e9c6a6a..25409a771 100644 --- a/tests/suite/screens/views/prompts.py +++ b/tests/suite/screens/views/prompts.py @@ -4,14 +4,10 @@ from sampletones_application.tags.general import SUF_BUTTON, SUF_BUTTON_CANCEL, SUF_BUTTON_OK, SUF_BUTTON_SAVE from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import ( - BUTTON_TYPE, - WindowReading, - is_tag_within, - read_shown_labels, - read_shown_texts, - read_windows, -) +from tests.suite.screens.dearpygui.items.reading import is_tag_within +from tests.suite.screens.dearpygui.items.regions import WindowReading, read_windows +from tests.suite.screens.dearpygui.items.texts import read_shown_labels, read_shown_texts +from tests.suite.screens.dearpygui.items.types import BUTTON_TYPE class MissingPromptError(AssertionError): @@ -49,6 +45,7 @@ def shown_windows(self) -> List[WindowReading]: ] def is_shown(self) -> bool: + """Whether a prompt of this kind stands on the screen.""" return self.window() is not None def title(self) -> str: @@ -82,6 +79,7 @@ def _press(self, suffix: str) -> None: self._hand.click(compose_tag(self._standing().alias, suffix, SUF_BUTTON)) def _standing(self) -> WindowReading: + """The window of the prompt on the screen; raises ``MissingPromptError`` when none stands.""" window = self.window() if window is None: raise MissingPromptError(f"No prompt under '{self._tag}' stands on the screen") diff --git a/tests/suite/screens/views/reconstructions.py b/tests/suite/screens/views/reconstructions.py index d19d88961..4011394b6 100644 --- a/tests/suite/screens/views/reconstructions.py +++ b/tests/suite/screens/views/reconstructions.py @@ -22,7 +22,8 @@ from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item, read_texts, read_value +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.texts import read_texts from tests.suite.screens.dearpygui.semantic import choose from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.instruments import Instruments @@ -83,7 +84,7 @@ def can_save(self) -> bool: def open_file(self) -> str: """The whole path of the open reconstruction's file, as the hover over its shortened path shows it. - Nothing open, or a reconstruction holding no file, reads as the status the line shows instead. + While nothing is open, or the reconstruction has no file yet, the hover shows the status of the line. """ return " ".join(self._bridge.ask(lambda: read_texts(OPEN_FILE_TOOLTIP))) @@ -100,7 +101,7 @@ def can_retune(self) -> bool: return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)).enabled def retune_lock_explained(self) -> bool: - """Whether hovering the NES frequency field explains why it takes no new rate.""" + """Whether hovering the NES frequency field shows why the rate is locked.""" return self._bridge.ask( lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED) ).shown @@ -116,6 +117,7 @@ def audio_source(self) -> str: return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE))) def can_choose_audio_source(self) -> bool: + """Whether the switch above the waveform answers a press.""" return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE)).enabled def choose_audio_source(self, label: str) -> None: @@ -133,6 +135,7 @@ def channel_plays(self, channel: ChannelName) -> bool: return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled def tick_channel(self, channel: ChannelName) -> None: + """Clicks the box of ``channel`` above the waveform, which flips its tick.""" self._hand.click(_channel_box(channel)) diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py index f7cda28ca..8840e7d37 100644 --- a/tests/suite/screens/views/sequencer.py +++ b/tests/suite/screens/views/sequencer.py @@ -24,7 +24,10 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_region_view, read_table +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.regions import read_region_view, read_table +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.dearpygui.keys import IMGUI_ENTER from tests.suite.screens.views.browsers import FileTree from tests.suite.screens.views.history import History @@ -167,6 +170,7 @@ def positions(self) -> int: ) def click(self, channel: Optional[ChannelName], position: int) -> None: + """Clicks the entry of ``channel`` at frame ``position``, scrolling it into view first.""" entry = self._bridge.ask(lambda: _order_entry(channel, position)) self._hand.scroll_into_view(entry) self._hand.click(entry) @@ -185,6 +189,7 @@ def __init__( self.retune_prompt = Prompt(bridge, hand, TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY) def nes_frequency(self) -> int: + """The NES frequency the field holds.""" return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY))) def retype_nes_frequency(self, frequency: int) -> None: diff --git a/tests/suite/screens/views/source.py b/tests/suite/screens/views/source.py index 49334f18f..60bab765e 100644 --- a/tests/suite/screens/views/source.py +++ b/tests/suite/screens/views/source.py @@ -8,7 +8,7 @@ from sampletones_core.constants.enums import ChannelName from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_theme +from tests.suite.screens.dearpygui.items.colors import read_theme class SourceSettings: @@ -27,14 +27,18 @@ def subject(self) -> str: return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT))) def channel_box(self, channel: ChannelName) -> str: + """The tag of the checkbox that lets the row convert with ``channel``.""" return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel, SUF_CHANNELS, SUF_CHECKBOX) def channel_ticked(self, channel: ChannelName) -> bool: + """Whether the checkbox of ``channel`` stands ticked.""" return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(channel)))) def channel_theme(self, channel: ChannelName) -> Optional[str]: + """The theme the checkbox of ``channel`` wears, or ``None`` while it wears none.""" return self._bridge.ask(lambda: read_theme(self.channel_box(channel))) def tick(self, channel: ChannelName) -> None: + """Clicks the checkbox of ``channel``, scrolling it into view first.""" self._hand.scroll_into_view(self.channel_box(channel)) self._hand.click(self.channel_box(channel)) diff --git a/tests/suite/screens/views/stems.py b/tests/suite/screens/views/stems.py index c8dc7935b..51947b5a8 100644 --- a/tests/suite/screens/views/stems.py +++ b/tests/suite/screens/views/stems.py @@ -10,7 +10,9 @@ from sampletones_application.ui.elements.stems.tags import StemsTags from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import enclosing_regions, read_item, read_label, read_value +from tests.suite.screens.dearpygui.items.reading import read_item, read_value +from tests.suite.screens.dearpygui.items.regions import enclosing_regions +from tests.suite.screens.dearpygui.items.texts import read_label from tests.suite.screens.views.prompts import Prompt @@ -33,10 +35,12 @@ def __init__( self.remove_prompt = Prompt(bridge, hand, TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION) def has_row(self, key: str) -> bool: + """Whether a row for the recording ``key`` stands on the screen.""" reading = self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_GROUP))) return reading.exists and reading.shown def name(self, key: str) -> str: + """The name the row of ``key`` shows.""" return self._bridge.ask(lambda: read_label(self.tags.row(key, SUF_TEXT))) def is_highlighted(self, key: str) -> bool: @@ -59,6 +63,7 @@ def remove(self, key: str) -> None: self._hand.click(self._in_view(self.tags.row(key, SUF_BUTTON))) def click(self, key: str) -> None: + """Clicks the name of the row of ``key``, which picks it.""" self._hand.click(self._in_view(self.tags.row(key, SUF_TEXT))) def reveal(self, key: str) -> None: @@ -70,6 +75,7 @@ def hears(self, key: str) -> bool: return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_CHECKBOX)))) def tick(self, key: str) -> None: + """Clicks the box of the row of ``key``, which flips whether its recording is heard.""" self._hand.click(self._in_view(self.tags.row(key, SUF_CHECKBOX))) def _in_view(self, item: str) -> str: diff --git a/tests/suite/screens/views/tabs.py b/tests/suite/screens/views/tabs.py index 5c0d2c232..8596db120 100644 --- a/tests/suite/screens/views/tabs.py +++ b/tests/suite/screens/views/tabs.py @@ -10,7 +10,7 @@ ) from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_selected_tab +from tests.suite.screens.dearpygui.items.reading import read_selected_tab TAB_TAGS: Final[Dict[Tab, str]] = { Tab.MAIN: TAG_GLOBAL_TAB_MAIN, @@ -18,6 +18,7 @@ Tab.SEQUENCER: TAG_GLOBAL_TAB_SEQUENCER, Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS, } + TABS_BY_TAG: Final[Dict[str, Tab]] = {tag: tab for tab, tag in TAB_TAGS.items()} diff --git a/tests/suite/screens/views/tracker.py b/tests/suite/screens/views/tracker.py index 1759ef41a..bfe30dd2a 100644 --- a/tests/suite/screens/views/tracker.py +++ b/tests/suite/screens/views/tracker.py @@ -10,7 +10,10 @@ from tests.suite.screens.dearpygui.bridge import Bridge from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import Item, read_item, read_label, read_theme_text_color +from tests.suite.screens.dearpygui.items.colors import read_theme_text_color +from tests.suite.screens.dearpygui.items.reading import read_item +from tests.suite.screens.dearpygui.items.texts import read_label +from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT from tests.suite.screens.dearpygui.reach import UnreachableError @@ -70,7 +73,7 @@ def click_header(self, channel: ChannelName) -> None: self._hand.click(label) def text_color(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[Tuple[float, ...]]: - """The color one slot's text is drawn in, the theme it wears states.""" + """The color one slot's text is drawn in, as the theme the slot wears sets it.""" def read() -> Optional[Tuple[float, ...]]: theme = tracker_cell_theme(row, channel, subcolumn) @@ -79,6 +82,7 @@ def read() -> Optional[Tuple[float, ...]]: return self._bridge.ask(read) def label(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> str: + """What one slot reads.""" return self._bridge.ask(lambda: read_label(tracker_cell(row, channel, subcolumn))) def labels(self, row: int, channel: Optional[ChannelName]) -> Tuple[str, ...]: @@ -86,6 +90,7 @@ def labels(self, row: int, channel: Optional[ChannelName]) -> Tuple[str, ...]: return tuple(self.label(row, channel, subcolumn) for subcolumn in SUBCOLUMNS) def theme(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + """The theme one slot's text wears, or ``None`` while it wears none.""" return self._bridge.ask(lambda: tracker_cell_theme(row, channel, subcolumn)) def click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: @@ -101,6 +106,7 @@ def shift_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubCo self._hand.click_holding(item, [IMGUI_LEFT_SHIFT]) def right_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot with the right button, which opens its menu.""" item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) self._hand.scroll_into_view(item) self._hand.right_click(item) @@ -122,6 +128,7 @@ def is_row_tinted(self, row: int) -> bool: ) def octave(self) -> int: + """The octave the octave field holds.""" return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE))) def raise_octave(self) -> None: diff --git a/tests/suite/screens/views/waveform.py b/tests/suite/screens/views/waveform.py index 09f58caee..26b660fd0 100644 --- a/tests/suite/screens/views/waveform.py +++ b/tests/suite/screens/views/waveform.py @@ -13,7 +13,7 @@ from tests.suite.screens.dearpygui.bridge import ONE_FRAME, Bridge from tests.suite.screens.dearpygui.geometry import Point, Rect from tests.suite.screens.dearpygui.hand import Hand -from tests.suite.screens.dearpygui.items import read_item +from tests.suite.screens.dearpygui.items.reading import read_item from tests.suite.screens.dearpygui.plots import settled_plot_pointer LINE_SERIES_TYPE: Final[str] = "mvAppItemType::mvLineSeries" @@ -98,12 +98,15 @@ def zoom_in(self, fraction: float, notches: int) -> None: self._hand.wheel_at(self.point(fraction), -notches) def click(self, fraction: float) -> None: + """Clicks the plot ``fraction`` of the way across it.""" self._hand.click_at(self.point(fraction)) def double_click(self, fraction: float) -> None: + """Double-clicks the plot ``fraction`` of the way across it.""" self._hand.double_click_at(self.point(fraction)) def triple_click(self, fraction: float) -> None: + """Triple-clicks the plot ``fraction`` of the way across it.""" self._hand.triple_click_at(self.point(fraction)) def drag(self, start: float, end: float) -> None: diff --git a/tests/suite/screens/vocabulary/exports.py b/tests/suite/screens/vocabulary/exports.py new file mode 100644 index 000000000..5f5858c25 --- /dev/null +++ b/tests/suite/screens/vocabulary/exports.py @@ -0,0 +1,5 @@ +from typing import Final + +EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" +NSF_TYPE: Final[str] = "global.dialog.filter.nsf" +NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" diff --git a/tests/suite/screens/vocabulary/instruments.py b/tests/suite/screens/vocabulary/instruments.py new file mode 100644 index 000000000..e6e1c52db --- /dev/null +++ b/tests/suite/screens/vocabulary/instruments.py @@ -0,0 +1,3 @@ +from typing import Final + +TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" diff --git a/tests/suite/screens/vocabulary/libraries.py b/tests/suite/screens/vocabulary/libraries.py new file mode 100644 index 000000000..371b877ff --- /dev/null +++ b/tests/suite/screens/vocabulary/libraries.py @@ -0,0 +1,3 @@ +from typing import Final + +LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" diff --git a/tests/suite/screens/vocabulary/settings.py b/tests/suite/screens/vocabulary/settings.py new file mode 100644 index 000000000..2594f5111 --- /dev/null +++ b/tests/suite/screens/vocabulary/settings.py @@ -0,0 +1,3 @@ +from typing import Final + +LISTENING: Final[str] = "settings.keybindings.message.capturing" diff --git a/tests/suite/screens/world.py b/tests/suite/screens/world.py deleted file mode 100644 index a9852c97e..000000000 --- a/tests/suite/screens/world.py +++ /dev/null @@ -1,282 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Final, Optional, Protocol, Tuple - -from sampletones_application.config.managers.application import ApplicationConfigManager -from sampletones_application.config.managers.state import ApplicationStateManager -from sampletones_application.config.profile import UserProfile -from sampletones_application.config.session.application.config import ApplicationConfig -from sampletones_application.config.session.state.state import ApplicationState -from sampletones_application.config.session.state.window import ViewportState -from sampletones_core.configs import Config -from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT -from sampletones_shared.paths.user import CONFIG_PATH, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY -from tests.suite.screens.environment import SCREEN_SIZE -from tests.suite.screens.seeds import ( - ArrangedProject, - LongEnvelopeProject, - MiniLibrary, - OverlongProject, - PlayableReconstruction, - Recording, - ReleasingInstrumentFile, - StoredProject, - StoredReconstruction, - TwoTuningsProject, - stored_recording, -) - -OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" -OTHER_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Other.stn" -SONG: Final[Path] = PROJECTS_DIRECTORY / "Song.stp" -SONG_SAMPLE: Final[str] = "Lead" -SONG_INSTRUMENT: Final[str] = "Pad" -BASS: Final[str] = "bass.wav" -LEAD: Final[str] = "lead.wav" -DOCUMENT_RECORDING_SECONDS: Final[float] = 0.3 -DOCUMENT_RECORDING_FREQUENCY: Final[float] = 220.0 -PLAYABLE_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Playable.stn" -SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" -STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn" -SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn" -TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") -STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") -STEM_FRAMES: Final[int] = 60 -PLAYABLE_FRAMES: Final[int] = 600 -FRAMES_PER_SECOND: Final[int] = 60 -ARRANGED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Arranged.stp" -LINE: Final[str] = "Line" -BASS_VOICE: Final[str] = "Bass" -PAD: Final[str] = "Pad" -PAD_ROW: Final[int] = 4 -BASS_ROW: Final[int] = 8 -TAKE_FREQUENCY: Final[float] = 220.0 -SINGLE_ORDER_FRAME: Final[int] = 1 -LOOPING_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Looping.stp" -LOOPING_ORDER_FRAMES: Final[int] = 2 -OVERLONG_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Overlong.stp" -OVERLONG_SAMPLE: Final[str] = "Turning" -OVERLONG_ORDER_FRAMES: Final[int] = 12 -TWO_TUNINGS_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Tunings.stp" -RETUNED_A4: Final[float] = 432.0 -LONG_ENVELOPES_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Envelopes.stp" -LONG_VOICE: Final[str] = "Long" -LONG_ITEMS: Final[int] = 600 -MIDDLING_VOICE: Final[str] = "Middling" -MIDDLING_ITEMS: Final[int] = 300 -INSTRUMENTS_FOLDER: Final[str] = "instruments" -RELEASING_INSTRUMENT: Final[str] = "Releasing" - - -class HomeFile(Protocol): - """Something a scenario's home holds before the application starts: a recording, a document, a library.""" - - def write(self) -> None: - """Writes it under the home of the scenario's process.""" - - -@dataclass(frozen=True) -class World: - """What a scenario's home holds before the application draws its first frame. - - Each file is written by the same model and the same manager the application reads it with, so - a home seeded here is a home a previous run could have left. - - Attributes: - state: The session the application restores, or ``None`` for a home no run has left one in. - application_config: The application's settings, or ``None`` for a home holding none. - config: The reconstruction settings in the documents folder, or ``None`` for a home holding none. - files: Everything else the home holds. - """ - - state: Optional[ApplicationState] - application_config: Optional[ApplicationConfig] - config: Optional[Config] - files: Tuple[HomeFile, ...] - - def write(self, profile: UserProfile) -> None: - """Lays the world out in the home ``profile`` reads its settings from.""" - if self.state is not None: - state_manager = ApplicationStateManager(profile.state) - state_manager.state = self.state - state_manager.save() - - if self.application_config is not None: - config_manager = ApplicationConfigManager(profile.config) - config_manager.config = self.application_config - config_manager.save() - - if self.config is not None: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - self.config.save(CONFIG_PATH) - - for file in self.files: - file.write() - - -def screen_filling_state() -> ApplicationState: - """A session whose window spans the scenario's screen, which the application fits within its margins.""" - return ApplicationState( - viewport=ViewportState( - width=SCREEN_SIZE.width, - height=SCREEN_SIZE.height, - x=0, - y=0, - ) - ) - - -def one_worker_config() -> Config: - """Reconstruction settings running one job at a time, which keeps a scenario's run on a worker of its own.""" - config = Config() - general = config.general.model_copy(update={"max_workers": 1}) - return config.model_copy(update={"general": general}) - - -def converting_world(files: Tuple[HomeFile, ...]) -> World: - """A home ready to convert: one-worker settings, a small library built for them, and ``files``.""" - config = one_worker_config() - return World( - state=screen_filling_state(), - application_config=None, - config=config, - files=(MiniLibrary(config), *files), - ) - - -def lived_in_world() -> World: - """The home of a user who ran the application once: a session left behind, and nothing else.""" - return World( - state=screen_filling_state(), - application_config=None, - config=None, - files=(), - ) - - -def documents_world() -> World: - """A home ready to convert that holds the documents a user puts away and replaces. - - It holds two reconstructions, :data:`OPEN_RECONSTRUCTION` and :data:`OTHER_RECONSTRUCTION`, - with the recording they name; the project :data:`SONG` with a sample and an instrument; and - two recordings to convert, :data:`BASS` and :data:`LEAD`, in the home itself. - """ - recordings = tuple( - Recording( - destination=Path.cwd() / name, - seconds=DOCUMENT_RECORDING_SECONDS, - frequency=DOCUMENT_RECORDING_FREQUENCY, - ) - for name in (BASS, LEAD) - ) - return converting_world( - ( - *recordings, - StoredReconstruction(OPEN_RECONSTRUCTION), - StoredReconstruction(OTHER_RECONSTRUCTION), - stored_recording(), - StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), - ) - ) - - -def playing_world() -> World: - """A home holding reconstructions that play: two of one recording each, and one of three recordings in turn. - - :data:`PLAYABLE_RECONSTRUCTION` and :data:`SECOND_PLAYABLE` play the first and the second of - :data:`TAKES`, the shorter :data:`STEMS_RECONSTRUCTION` plays the three :data:`STEM_TAKES` in - turn, and :data:`SHORT_RECONSTRUCTION` plays the first of them alone. The home holds the stored - :data:`OPEN_RECONSTRUCTION`, sounding every channel, and the project :data:`SONG` besides. - """ - takes = tuple(Path.cwd() / name for name in TAKES) - stem_takes = tuple(Path.cwd() / name for name in STEM_TAKES) - recordings = tuple( - Recording(destination=take, seconds=frames / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY * (index + 1)) - for paths, frames in ((takes, PLAYABLE_FRAMES), (stem_takes, STEM_FRAMES)) - for index, take in enumerate(paths) - ) - return World( - state=screen_filling_state(), - application_config=None, - config=None, - files=( - *recordings, - PlayableReconstruction(PLAYABLE_RECONSTRUCTION, takes[:1], PLAYABLE_FRAMES), - PlayableReconstruction(SECOND_PLAYABLE, takes[1:2], PLAYABLE_FRAMES), - PlayableReconstruction(STEMS_RECONSTRUCTION, stem_takes, STEM_FRAMES), - PlayableReconstruction(SHORT_RECONSTRUCTION, stem_takes[:1], STEM_FRAMES), - StoredReconstruction(OPEN_RECONSTRUCTION), - stored_recording(), - StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), - ), - ) - - -def sequencer_world() -> World: - """A home holding :data:`ARRANGED_PROJECT`, its voices placed on its first pattern, and the playing world's files. - - The project's sample :data:`LINE` starts the pattern on Pulse 1, its hand-written :data:`PAD` - comes in on Pulse 2 at :data:`PAD_ROW`, and its sample :data:`BASS_VOICE` on the triangle at - :data:`BASS_ROW`. - """ - playing = playing_world() - return World( - state=playing.state, - application_config=None, - config=None, - files=( - *playing.files, - arranged_project(ARRANGED_PROJECT, SINGLE_ORDER_FRAME), - ), - ) - - -def exporting_world() -> World: - """The sequencer's home, holding besides the projects an export meets at its limits. - - :data:`LOOPING_PROJECT` is :data:`ARRANGED_PROJECT` with its pattern played twice in the order. - :data:`OVERLONG_PROJECT` changes every channel at every tick through :data:`OVERLONG_ORDER_FRAMES` - frames. :data:`TWO_TUNINGS_PROJECT` holds :data:`LINE` at the default tuning and - :data:`BASS_VOICE` with A4 at :data:`RETUNED_A4` hertz. :data:`LONG_ENVELOPES_PROJECT` holds - :data:`LONG_VOICE` and :data:`MIDDLING_VOICE`, whose volume envelopes run :data:`LONG_ITEMS` and - :data:`MIDDLING_ITEMS` items. The home's :data:`INSTRUMENTS_FOLDER` holds the FamiTracker instrument - :data:`RELEASING_INSTRUMENT`, whose volume states a release point. - """ - sequencer = sequencer_world() - return World( - state=sequencer.state, - application_config=None, - config=None, - files=( - *sequencer.files, - arranged_project(LOOPING_PROJECT, LOOPING_ORDER_FRAMES), - OverlongProject(OVERLONG_PROJECT, sample=OVERLONG_SAMPLE, order_frames=OVERLONG_ORDER_FRAMES), - TwoTuningsProject(TWO_TUNINGS_PROJECT, line=LINE, bass=BASS_VOICE, a4_frequency=RETUNED_A4), - LongEnvelopeProject( - LONG_ENVELOPES_PROJECT, - long=LONG_VOICE, - long_items=LONG_ITEMS, - middling=MIDDLING_VOICE, - middling_items=MIDDLING_ITEMS, - ), - ReleasingInstrumentFile(releasing_instrument(), name=RELEASING_INSTRUMENT), - ), - ) - - -def releasing_instrument() -> Path: - """Where the home holds the FamiTracker instrument :data:`RELEASING_INSTRUMENT`.""" - return Path.cwd() / INSTRUMENTS_FOLDER / f"{RELEASING_INSTRUMENT}{EXT_FILE_INSTRUMENT}" - - -def arranged_project(destination: Path, order_frames: int) -> ArrangedProject: - """The arranged project laid at ``destination``, its pattern played ``order_frames`` times in the order.""" - return ArrangedProject( - destination, - line=LINE, - bass=BASS_VOICE, - pad=PAD, - pad_row=PAD_ROW, - bass_row=BASS_ROW, - order_frames=order_frames, - ) diff --git a/tests/suite/screens/worlds/__init__.py b/tests/suite/screens/worlds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/worlds/home.py b/tests/suite/screens/worlds/home.py new file mode 100644 index 000000000..06da69ce5 --- /dev/null +++ b/tests/suite/screens/worlds/home.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import Optional, Protocol, Tuple + +from sampletones_application.config.managers.application import ApplicationConfigManager +from sampletones_application.config.managers.state import ApplicationStateManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH +from tests.suite.screens.environment import SCREEN_SIZE + + +class HomeFile(Protocol): + """Something a scenario's home holds before the application starts: a recording, a document, a library.""" + + def write(self) -> None: + """Writes it under the home of the scenario's process.""" + + +@dataclass(frozen=True) +class World: + """What a scenario's home holds before the application draws its first frame. + + Each file is written by the same model and the same manager the application reads it with, so + a home seeded here is a home a previous run could have left. + + Attributes: + state: The session the application restores, or ``None`` for a home no run has left one in. + application_config: The application's settings, or ``None`` for a home holding none. + config: The reconstruction settings in the documents folder, or ``None`` for a home holding none. + files: Everything else the home holds. + """ + + state: Optional[ApplicationState] + application_config: Optional[ApplicationConfig] + config: Optional[Config] + files: Tuple[HomeFile, ...] + + def write(self, profile: UserProfile) -> None: + """Lays the world out in the home ``profile`` reads its settings from.""" + if self.state is not None: + state_manager = ApplicationStateManager(profile.state) + state_manager.state = self.state + state_manager.save() + + if self.application_config is not None: + config_manager = ApplicationConfigManager(profile.config) + config_manager.config = self.application_config + config_manager.save() + + if self.config is not None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + self.config.save(CONFIG_PATH) + + for file in self.files: + file.write() + + +def screen_filling_state() -> ApplicationState: + """A session whose window spans the scenario's screen, which the application fits within its margins.""" + return ApplicationState( + viewport=ViewportState( + width=SCREEN_SIZE.width, + height=SCREEN_SIZE.height, + x=0, + y=0, + ) + ) + + +def one_worker_config() -> Config: + """Reconstruction settings running one job at a time, which keeps a scenario's run on a worker of its + own. + """ + config = Config() + general = config.general.model_copy(update={"max_workers": 1}) + return config.model_copy(update={"general": general}) diff --git a/tests/suite/screens/worlds/recordings.py b/tests/suite/screens/worlds/recordings.py new file mode 100644 index 000000000..d48e3961d --- /dev/null +++ b/tests/suite/screens/worlds/recordings.py @@ -0,0 +1,109 @@ +from pathlib import Path +from typing import Final, Tuple + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.seeds.libraries import MiniLibrary +from tests.suite.screens.seeds.projects import StoredProject +from tests.suite.screens.seeds.reconstructions import PlayableReconstruction, StoredReconstruction +from tests.suite.screens.seeds.recordings import Recording, stored_recording +from tests.suite.screens.worlds.home import HomeFile, World, one_worker_config, screen_filling_state + +OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" +OTHER_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Other.stn" +SONG: Final[Path] = PROJECTS_DIRECTORY / "Song.stp" +SONG_SAMPLE: Final[str] = "Lead" +SONG_INSTRUMENT: Final[str] = "Pad" +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DOCUMENT_RECORDING_SECONDS: Final[float] = 0.3 +DOCUMENT_RECORDING_FREQUENCY: Final[float] = 220.0 +PLAYABLE_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Playable.stn" +SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn" +SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn" +TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") +STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") +STEM_FRAMES: Final[int] = 60 +PLAYABLE_FRAMES: Final[int] = 600 +FRAMES_PER_SECOND: Final[int] = 60 +TAKE_FREQUENCY: Final[float] = 220.0 + + +def converting_world(files: Tuple[HomeFile, ...]) -> World: + """A home ready to convert: one-worker settings, a small library built for them, and ``files``.""" + config = one_worker_config() + return World( + state=screen_filling_state(), + application_config=None, + config=config, + files=(MiniLibrary(config), *files), + ) + + +def lived_in_world() -> World: + """The home of a user who ran the application once: a session left behind, and nothing else.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=(), + ) + + +def documents_world() -> World: + """A home ready to convert that holds the documents a user puts away and replaces. + + It holds two reconstructions, :data:`OPEN_RECONSTRUCTION` and :data:`OTHER_RECONSTRUCTION`, + with the recording they name; the project :data:`SONG` with a sample and an instrument; and + two recordings to convert, :data:`BASS` and :data:`LEAD`, in the home itself. + """ + recordings = tuple( + Recording( + destination=Path.cwd() / name, + seconds=DOCUMENT_RECORDING_SECONDS, + frequency=DOCUMENT_RECORDING_FREQUENCY, + ) + for name in (BASS, LEAD) + ) + return converting_world( + ( + *recordings, + StoredReconstruction(OPEN_RECONSTRUCTION), + StoredReconstruction(OTHER_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ) + ) + + +def playing_world() -> World: + """A home holding reconstructions that play: two of one recording each, and one of three recordings in + turn. + + :data:`PLAYABLE_RECONSTRUCTION` and :data:`SECOND_PLAYABLE` play the first and the second of + :data:`TAKES`, the shorter :data:`STEMS_RECONSTRUCTION` plays the three :data:`STEM_TAKES` in + turn, and :data:`SHORT_RECONSTRUCTION` plays the first of them alone. The home holds the stored + :data:`OPEN_RECONSTRUCTION`, sounding every channel, and the project :data:`SONG` besides. + """ + takes = tuple(Path.cwd() / name for name in TAKES) + stem_takes = tuple(Path.cwd() / name for name in STEM_TAKES) + recordings = tuple( + Recording(destination=take, seconds=frames / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY * (index + 1)) + for paths, frames in ((takes, PLAYABLE_FRAMES), (stem_takes, STEM_FRAMES)) + for index, take in enumerate(paths) + ) + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=( + *recordings, + PlayableReconstruction(PLAYABLE_RECONSTRUCTION, takes[:1], PLAYABLE_FRAMES), + PlayableReconstruction(SECOND_PLAYABLE, takes[1:2], PLAYABLE_FRAMES), + PlayableReconstruction(STEMS_RECONSTRUCTION, stem_takes, STEM_FRAMES), + PlayableReconstruction(SHORT_RECONSTRUCTION, stem_takes[:1], STEM_FRAMES), + StoredReconstruction(OPEN_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ), + ) diff --git a/tests/suite/screens/worlds/songs.py b/tests/suite/screens/worlds/songs.py new file mode 100644 index 000000000..98b2e2566 --- /dev/null +++ b/tests/suite/screens/worlds/songs.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.seeds.projects import ( + ArrangedProject, + LongEnvelopeProject, + OverlongProject, + ReleasingInstrumentFile, + TwoTuningsProject, +) +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import playing_world + +ARRANGED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Arranged.stp" +LINE: Final[str] = "Line" +BASS_VOICE: Final[str] = "Bass" +PAD: Final[str] = "Pad" +PAD_ROW: Final[int] = 4 +BASS_ROW: Final[int] = 8 +SINGLE_ORDER_FRAME: Final[int] = 1 +LOOPING_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Looping.stp" +LOOPING_ORDER_FRAMES: Final[int] = 2 +OVERLONG_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Overlong.stp" +OVERLONG_SAMPLE: Final[str] = "Turning" +OVERLONG_ORDER_FRAMES: Final[int] = 12 +TWO_TUNINGS_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Tunings.stp" +RETUNED_A4: Final[float] = 432.0 +LONG_ENVELOPES_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Envelopes.stp" +LONG_VOICE: Final[str] = "Long" +LONG_ITEMS: Final[int] = 600 +MIDDLING_VOICE: Final[str] = "Middling" +MIDDLING_ITEMS: Final[int] = 300 +INSTRUMENTS_FOLDER: Final[str] = "instruments" +RELEASING_INSTRUMENT: Final[str] = "Releasing" + + +def sequencer_world() -> World: + """A home holding :data:`ARRANGED_PROJECT`, its voices placed on its first pattern, and the playing + world's files. + + The project's sample :data:`LINE` starts the pattern on Pulse 1, its hand-written :data:`PAD` + comes in on Pulse 2 at :data:`PAD_ROW`, and its sample :data:`BASS_VOICE` on the triangle at + :data:`BASS_ROW`. + """ + playing = playing_world() + return World( + state=playing.state, + application_config=None, + config=None, + files=( + *playing.files, + arranged_project(ARRANGED_PROJECT, SINGLE_ORDER_FRAME), + ), + ) + + +def exporting_world() -> World: + """The sequencer's home, holding besides the projects an export meets at its limits. + + :data:`LOOPING_PROJECT` is :data:`ARRANGED_PROJECT` with its pattern played twice in the order. + :data:`OVERLONG_PROJECT` changes every channel at every tick through :data:`OVERLONG_ORDER_FRAMES` + frames. :data:`TWO_TUNINGS_PROJECT` holds :data:`LINE` at the default tuning and + :data:`BASS_VOICE` with A4 at :data:`RETUNED_A4` hertz. :data:`LONG_ENVELOPES_PROJECT` holds + :data:`LONG_VOICE` and :data:`MIDDLING_VOICE`, whose volume envelopes run :data:`LONG_ITEMS` and + :data:`MIDDLING_ITEMS` items. The home's :data:`INSTRUMENTS_FOLDER` holds the FamiTracker instrument + :data:`RELEASING_INSTRUMENT`, whose volume states a release point. + """ + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=None, + config=None, + files=( + *sequencer.files, + arranged_project(LOOPING_PROJECT, LOOPING_ORDER_FRAMES), + OverlongProject(OVERLONG_PROJECT, sample=OVERLONG_SAMPLE, order_frames=OVERLONG_ORDER_FRAMES), + TwoTuningsProject(TWO_TUNINGS_PROJECT, line=LINE, bass=BASS_VOICE, a4_frequency=RETUNED_A4), + LongEnvelopeProject( + LONG_ENVELOPES_PROJECT, + long=LONG_VOICE, + long_items=LONG_ITEMS, + middling=MIDDLING_VOICE, + middling_items=MIDDLING_ITEMS, + ), + ReleasingInstrumentFile(releasing_instrument(), name=RELEASING_INSTRUMENT), + ), + ) + + +def releasing_instrument() -> Path: + """Where the home holds the FamiTracker instrument :data:`RELEASING_INSTRUMENT`.""" + return Path.cwd() / INSTRUMENTS_FOLDER / f"{RELEASING_INSTRUMENT}{EXT_FILE_INSTRUMENT}" + + +def arranged_project(destination: Path, order_frames: int) -> ArrangedProject: + """The arranged project laid at ``destination``, its pattern played ``order_frames`` times in the order.""" + return ArrangedProject( + destination, + line=LINE, + bass=BASS_VOICE, + pad=PAD, + pad_row=PAD_ROW, + bass_row=BASS_ROW, + order_frames=order_frames, + ) diff --git a/tests/suite/screens/written.py b/tests/suite/screens/written.py index 36a8fb89f..0cd25bd8c 100644 --- a/tests/suite/screens/written.py +++ b/tests/suite/screens/written.py @@ -19,5 +19,7 @@ def written_application_config() -> ApplicationConfig: def written_config() -> Config: - """The reconstruction settings left in the documents folder, read by the manager the next start reads them with.""" + """The reconstruction settings left in the documents folder, read by the manager the next start reads + them with. + """ return ConfigManager(CONFIG_PATH).config From c851a519f49a2ef400bf7a184e6175064b3e8fa0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:05:47 +0200 Subject: [PATCH 17/19] Fixed: hovering a row that a rebuild removed --- .../ui/elements/tree/tree.py | 9 +++++ .../ui/panels/sequencer/voices/panel.py | 7 +++- tests/suite/status.py | 22 ++++++++++ .../ui/elements/tree/test_status_messages.py | 30 ++++++++++++++ .../ui/panels/sequencer/voices/test_hover.py | 40 ++++++++++++++++++- 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/suite/status.py diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 4c135937e..a74d41077 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -500,10 +500,19 @@ def _create_hover_callback( self, status_bar_callback: Optional[MessageCallback], ) -> Callback: + """The hover callback of a row, which names the row in the status bar and its detail tooltip. + + The hover is reported a frame after it happened, by which time a rebuilt tree may have taken + the row away, so the callback answers for the rows still standing. + """ + def hover_callback( _sender: Sender, app_data: int, ) -> None: + if not dpg.does_item_exist(app_data): + return + user_data = dpg.get_item_user_data(app_data) if status_bar_callback is not None: self._status_bar.set(status_bar_callback, user_data=user_data) diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 15de17f9d..06670d7fe 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -145,8 +145,13 @@ def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: """Says what the hovered row holds, which the id cell and the name cell both address. Both cells carry the row's position and its voice, so one handler covers the whole row and - a reader reads the same line wherever the pointer rests on it. + a reader reads the same line wherever the pointer rests on it. The hover is reported a frame + after it happened, by which time a rebuilt list may have taken the cell away, so the handler + answers for the cells still standing. """ + if not dpg.does_item_exist(app_data): + return + user_data = dpg.get_item_user_data(app_data) if not isinstance(user_data, tuple): return diff --git a/tests/suite/status.py b/tests/suite/status.py new file mode 100644 index 000000000..e9b1bb5cc --- /dev/null +++ b/tests/suite/status.py @@ -0,0 +1,22 @@ +from typing import Any, List, Union + +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_shared.types.callback import MessageCallback + + +class RecordedStatusBar(GUIStatusBar): + """A status bar keeping every message it is given, with no window or theme to draw them with. + + A test reading what a hover or a gesture says reads the messages, in the order they were set. + """ + + def __init__(self) -> None: # pylint: disable=super-init-not-called + self.messages: List[str] = [] + + def set( + self, + message_or_function: Union[str, MessageCallback], + *args: Any, + **kwargs: Any, + ) -> None: + self.messages.append(self.get_message(message_or_function, *args, **kwargs)) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py index 6b9d9e893..bd0982316 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py @@ -1,8 +1,21 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg import pytest from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.structures.tree import NodeType, TreeNode from tests.suite.language import FakeLanguageManager +from tests.suite.status import RecordedStatusBar + + +@pytest.fixture +def context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() def _panel() -> GUISequencerBrowserPanel: @@ -29,3 +42,20 @@ def test_the_message_names_what_the_row_holds( panel = _panel() assert panel._expandable_node_message(TreeNode("row", node_type=node_type)) == key + + +@pytest.mark.usefixtures("context") +class TestAHoverReportedAfterTheRowWent: + def test_a_row_a_rebuild_removed_leaves_the_status_bar_as_it_was(self) -> None: + """The hover arrives a frame late, and a row the rebuilt tree no longer holds says nothing.""" + status_bar = RecordedStatusBar() + panel = _panel() + panel._status_bar = status_bar + hovered = panel._create_hover_callback(lambda *_args, **_kwargs: "row") + with dpg.window(): + row = dpg.add_text("row") + dpg.delete_item(row) + + hovered(0, row) + + assert status_bar.messages == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py index ee54015ba..6addca213 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py @@ -1,5 +1,6 @@ -from typing import Final, Optional, Tuple +from typing import Final, Iterator, Optional, Tuple +import dearpygui.dearpygui as dpg import pytest from sampletones_application.categories.manager import LanguageManager @@ -17,6 +18,7 @@ from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint +from tests.suite.status import RecordedStatusBar SAMPLE_ID: Final[str] = "kick-id" INSTRUMENT_ID: Final[str] = "lead-id" @@ -38,6 +40,15 @@ INSTRUMENT_FOOTPRINT: Final[VoiceFootprintViewModel] = VoiceFootprintViewModel.from_instrument(PULSE_1_FOOTPRINT) +@pytest.fixture +def context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + def _panel(footprint: Optional[VoiceFootprintViewModel]) -> GUISequencerVoicesPanel: """The panel over the facts a hovered row reads, with no DearPyGui context behind it.""" language_manager = LanguageManager(LANG_EN) @@ -92,3 +103,30 @@ def test_a_voice_with_nothing_to_state_says_nothing( footprint: Optional[VoiceFootprintViewModel], ) -> None: assert _panel(footprint)._voice_status_message(voice_id) == "" + + +@pytest.mark.usefixtures("context") +class TestAHoverReportedAfterTheRowWent: + def test_a_standing_cell_names_its_voice(self) -> None: + status_bar = RecordedStatusBar() + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = status_bar + with dpg.window(): + cell = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + + panel._on_row_hovered(0, cell) + + assert len(status_bar.messages) == 1 + assert status_bar.messages[0].startswith("Kick") + + def test_a_cell_a_rebuild_removed_leaves_the_status_bar_as_it_was(self) -> None: + status_bar = RecordedStatusBar() + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = status_bar + with dpg.window(): + cell = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + dpg.delete_item(cell) + + panel._on_row_hovered(0, cell) + + assert status_bar.messages == [] From 3948aea48dfa441453573ba23d12910ed111e4a1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:06:13 +0200 Subject: [PATCH 18/19] Halved: screen workers on CI --- .github/workflows/ci.yml | 2 +- scripts/run_tests.py | 23 +++++++++++++++++------ tests/unit/scripts/test_run_tests.py | 16 ++++++++++------ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 961f204f7..ba613132b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: id: screens if: ${{ !cancelled() && steps.environment.outcome == 'success' && runner.os == 'Linux' }} shell: bash - run: $PYTHON scripts/run_tests.py screens + run: $PYTHON scripts/run_tests.py screens --screen-workers 2 - name: Keep what the failed screen scenarios left if: ${{ failure() && steps.screens.outcome == 'failure' }} diff --git a/scripts/run_tests.py b/scripts/run_tests.py index b324cb17f..d4d9b8989 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -16,7 +16,7 @@ PYTEST: Final[Tuple[str, ...]] = ("uv", "run", "python", "-m", "pytest") -def planned_passes(workers: str) -> Dict[str, Pass]: +def planned_passes(workers: str, screen_workers: str) -> Dict[str, Pass]: """The passes a test run is made of, by name: the covered suite, the doctests, the benchmarks and the screen scenarios. @@ -24,11 +24,13 @@ def planned_passes(workers: str) -> Dict[str, Pass]: covered suite runs across ``workers`` pytest workers. The benchmarks run serial, uncovered and with their output shown, so a measured duration is the code's own cost and its reading reaches the terminal. The screen scenarios drive the running application on a display of - each worker's own, each scenario in a process of its own, so they run uncovered across a few - workers, each drawing its frames on the processor. + each worker's own, each scenario in a process of its own, so they run uncovered across + ``screen_workers`` workers, each drawing its frames on the processor. Args: workers: The worker count for the covered suite, or ``auto`` for one per processor. + screen_workers: The worker count for the screen scenarios, each worker drawing one + application at a time. Returns: Dict[str, Pass]: Every pass, under its name. @@ -59,7 +61,7 @@ def planned_passes(workers: str) -> Dict[str, Pass]: Pass( SCREENS, "Running screen scenarios...", - (*PYTEST, SCREENS_DIRECTORY, "-n", SCREEN_WORKERS, "--no-cov"), + (*PYTEST, SCREENS_DIRECTORY, "-n", screen_workers, "--no-cov"), ), ) return {current.name: current for current in passes} @@ -68,16 +70,25 @@ def planned_passes(workers: str) -> Dict[str, Pass]: def main(argv: Sequence[str]) -> int: """Runs one pass of the tests and exits with the status pytest gave it.""" parser = argparse.ArgumentParser(description="Run one pass of the SampleToNES tests.") - parser.add_argument("name", choices=tuple(planned_passes(DEFAULT_WORKERS)), help="the pass to run") + parser.add_argument( + "name", + choices=tuple(planned_passes(DEFAULT_WORKERS, SCREEN_WORKERS)), + help="the pass to run", + ) parser.add_argument( "--workers", default=DEFAULT_WORKERS, help="pytest workers for the covered suite: a count, or auto for one per processor", ) + parser.add_argument( + "--screen-workers", + default=SCREEN_WORKERS, + help="pytest workers for the screen scenarios, each drawing one application at a time", + ) arguments = parser.parse_args(list(argv)) return run_pass( - planned_passes(arguments.workers)[arguments.name], + planned_passes(arguments.workers, arguments.screen_workers)[arguments.name], root=repository_root(), runner=run, environment=os.environ, diff --git a/tests/unit/scripts/test_run_tests.py b/tests/unit/scripts/test_run_tests.py index f371d201e..19bcea748 100644 --- a/tests/unit/scripts/test_run_tests.py +++ b/tests/unit/scripts/test_run_tests.py @@ -13,13 +13,13 @@ class TestPlannedPasses: def test_every_pass_is_found_under_its_name(self) -> None: - passes = run_tests.planned_passes(run_tests.DEFAULT_WORKERS) + passes = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS) assert all(name == current.name for name, current in passes.items()) assert set(passes) == {run_tests.SUITE, run_tests.DOCTESTS, run_tests.BENCHMARKS, run_tests.SCREENS} def test_the_suite_is_covered_across_the_workers_and_leaves_the_other_passes_out(self) -> None: - command = run_tests.planned_passes("auto")[run_tests.SUITE].command + command = run_tests.planned_passes("auto", run_tests.SCREEN_WORKERS)[run_tests.SUITE].command assert command[-5:] == ( "-n", @@ -30,19 +30,23 @@ def test_the_suite_is_covered_across_the_workers_and_leaves_the_other_passes_out ) def test_the_screen_scenarios_run_across_their_own_workers_uncovered(self) -> None: - command = run_tests.planned_passes("auto")[run_tests.SCREENS].command + command = run_tests.planned_passes("auto", "2")[run_tests.SCREENS].command assert SCREENS_DIRECTORY in command - assert command[-3:] == ("-n", run_tests.SCREEN_WORKERS, "--no-cov") + assert command[-3:] == ("-n", "2", "--no-cov") def test_the_doctests_read_the_sources_uncovered(self) -> None: - command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS)[run_tests.DOCTESTS].command + command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS)[ + run_tests.DOCTESTS + ].command assert "--doctest-modules" in command assert "--no-cov" in command def test_the_benchmarks_run_serial_uncovered_and_show_their_readings(self) -> None: - command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS)[run_tests.BENCHMARKS].command + command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS)[ + run_tests.BENCHMARKS + ].command assert BENCHMARKS_DIRECTORY in command assert "--no-cov" in command From cc4192677f86903ca28acc2098b8bf3c8e8b2440 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:06:25 +0200 Subject: [PATCH 19/19] Decoupled: screen gestures and waits from frame time --- docs/development/application/screens.md | 36 ++++- .../utils/callbacks/queue.py | 6 + .../gathering/test_navigating_and_clicking.py | 4 +- tests/screens/main/list/test_rows.py | 3 +- .../screens/main/mix/test_turning_to_a_mix.py | 3 +- tests/screens/main/row_settings/test_pick.py | 3 +- .../main/scan/test_reading_a_folder.py | 19 ++- .../instruments/test_audition.py | 26 ++-- .../instruments/test_note_keys.py | 14 +- tests/suite/screens/application/boundaries.py | 2 + tests/suite/screens/boundaries/audio.py | 45 +++++- tests/suite/screens/dearpygui/bridge.py | 51 ++++++- .../screens/dearpygui/gestures/arrival.py | 49 +++++- .../screens/dearpygui/gestures/errors.py | 4 + .../screens/dearpygui/gestures/keyboard.py | 51 +++---- .../screens/dearpygui/gestures/pointer.py | 140 ++++++++++++------ .../screens/dearpygui/gestures/witness.py | 62 ++++++++ tests/suite/screens/dearpygui/hand.py | 14 +- tests/suite/screens/dearpygui/xtest.py | 56 ++++++- tests/suite/screens/holds/scan.py | 27 ++++ tests/suite/screens/plugin/fixtures.py | 6 +- tests/suite/screens/plugin/hooks.py | 2 +- tests/suite/screens/render_thread.py | 4 + tests/suite/screens/screen.py | 11 ++ .../utils/callbacks/test_queue_reliability.py | 8 + 25 files changed, 507 insertions(+), 139 deletions(-) create mode 100644 tests/suite/screens/dearpygui/gestures/witness.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index d9aaee5b8..8f2c66b46 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -73,6 +73,12 @@ and a gesture waits for its control to be reachable and to stand still. A fixed retry hides a bug: a scenario that passes only sometimes is a bug. A count of frames appears in a scenario only where the count is the contract itself. +A wait's time runs in seconds and in frames: the frames a machine drawing 30 a second draws in those +seconds. The wait ends once both have run, so a machine drawing slowly gives the application as many frames +as a fast one before an expectation fails. A witness that lasts a moment is read where it stays: a sound +counts once it reaches the output device, and a stopped read that must still be winding down is held there +until the scenario lets it go. + ### 6. Every scenario is held to the same promises Every scenario is held to the same after-checks. A failing gesture is logged and swallowed so the interface @@ -126,8 +132,14 @@ region that takes the wheel, and the grip of a region's scrollbar is dragged whe wheel. Keys are pressed on the real keyboard, with modifiers held a frame before the key. A scenario names an -action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key, -so a key held across a slow frame arrives as one press. +action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key. + +Dear ImGui judges a press by time: two presses count as a double-click when they land within 0.30 s, and a +key held past 0.275 s repeats. A click and a key press therefore go down and come up in one go, and Dear +ImGui reads the press on one frame and the release on the next at any frame rate. A key stands down for a +single frame, too briefly to repeat, and the presses of a double-click land two frames apart. Where two +frames take longer than the double-click time, the hand raises `SlowFramesError` and says how far apart the +presses landed. A person lets go of a click some frames after pressing it, and DearPyGui answers a double-click as its second press goes down. A scenario holding that press for as long as a person does sees what the answer @@ -135,10 +147,16 @@ brings under a button still down. A gesture confirms that it arrived. Before a button goes down, the control must report the pointer resting on it: DearPyGui calls a control visible while the region it scrolls in clips it, and a press there lands -on something else. While a button or a named key is held, the application must read it as down within a -few frames, since a busy display hands input on late. Each step of a drag waits until the application -reads the pointer where the step put it, and the pointer rests at the end before the button comes up, as -a person stops before letting go. A press lost on the way fails where it was lost. +on something else. While a button or a modifier is held, the application must read it as down within a +few frames, since a busy display hands input on late. A click or a key press that came and went is +confirmed by a witness, a handler registry of the hand's own that counts every release and every +double-click Dear ImGui reads. Each step of a drag waits until the application reads the pointer where the +step put it, and the pointer rests at the end before the button comes up, as a person stops before letting +go. A press lost on the way fails where it was lost. + +A worker's scenarios take its display in turn, and a key or a button left down would meet the next +scenario's first gesture. A gesture that fails lets go of what it holds, and each scenario starts and ends +with every key and button of the display up. A plot reads the pointer as it draws, so the value it puts under the pointer trails the pointer by a frame. A scenario that aims by plot values reads one once the plot has kept it for two frames in a row. @@ -267,7 +285,9 @@ sentences. ## Running and watching -`make screens` runs every scenario across a few workers, each on an Xvfb of its own. `make system-deps` +`make screens` runs every scenario across a few workers, each on an Xvfb of its own. Each worker draws +on the processor, so CI runs two of them, which leaves each application more of a small runner's processors +for its frames. `make system-deps` installs Xvfb with the other development packages on Linux, and a missing server is named when the run starts. The scenarios run on Linux, and [the ledger](../bugs-and-todos.md) holds the other platforms. @@ -289,7 +309,7 @@ picture as evidence of a look, for a pull request rather than an assertion. | The fixtures a scenario asks for | `tests/suite/screens/plugin/fixtures.py`, `plugin/hold_fixtures.py` | | The render thread crossing and waiting | `tests/suite/screens/dearpygui/bridge.py` | | What a user can reach | `tests/suite/screens/dearpygui/reach.py`, `semantic.py` | -| The gestures | `tests/suite/screens/dearpygui/hand.py` and `gestures/`: `arrival.py` (waiting and confirming), `pointer.py`, `scrolling.py`, `keyboard.py` | +| The gestures | `tests/suite/screens/dearpygui/hand.py` and `gestures/`: `arrival.py` (waiting and confirming), `witness.py` (what Dear ImGui counted), `pointer.py`, `scrolling.py`, `keyboard.py` | | What a reading says of an item | `tests/suite/screens/dearpygui/items/`: `reading.py`, `regions.py`, `texts.py`, `colors.py`, `viewport.py` | | The display a worker draws on | `tests/suite/screens/dearpygui/display.py` | | The application under test and how it ends | `tests/suite/screens/application/` | diff --git a/src/sampletones_application/utils/callbacks/queue.py b/src/sampletones_application/utils/callbacks/queue.py index d8c2d6e4c..6a20293c0 100644 --- a/src/sampletones_application/utils/callbacks/queue.py +++ b/src/sampletones_application/utils/callbacks/queue.py @@ -74,6 +74,12 @@ def notify_frame(cls) -> None: with cls._lock: cls._frame_counter += 1 + @classmethod + def current_frame(cls) -> int: + """The frame the counter stands at, which a delay given to :meth:`add` counts from.""" + with cls._lock: + return cls._frame_counter + @classmethod def process(cls, budget_seconds: float) -> None: """Run the callbacks due at the current frame, up to a time budget. diff --git a/tests/screens/main/gathering/test_navigating_and_clicking.py b/tests/screens/main/gathering/test_navigating_and_clicking.py index f8bc563e3..edc5158c0 100644 --- a/tests/screens/main/gathering/test_navigating_and_clicking.py +++ b/tests/screens/main/gathering/test_navigating_and_clicking.py @@ -56,12 +56,12 @@ class TestAPlainClickOnARecording: def test_it_plays_and_the_gathering_gestures_gather(self, screen: Screen) -> None: """A click only plays, while Ctrl-click and the Add as stem entry add rows.""" converter = screen.main.converter - playback = screen.sequencer.playback def a_click_plays(screen: Screen) -> None: + heard = screen.sound_heard() screen.explorer.click(explorer_row(screen, home_path(KICK))) - screen.expect(playback.can_stop, bool, description="the recording playing") + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") assert converter.list.hint_shown() def ctrl_click_gathers(screen: Screen) -> None: diff --git a/tests/screens/main/list/test_rows.py b/tests/screens/main/list/test_rows.py index cc480a1a3..d9ca1c0f4 100644 --- a/tests/screens/main/list/test_rows.py +++ b/tests/screens/main/list/test_rows.py @@ -100,10 +100,11 @@ def test_a_double_click_plays_and_play_greys_once_the_file_is_gone(self, screen: def a_double_click_plays(screen: Screen) -> None: gather(screen, home_path(KICK)) + heard = screen.sound_heard() screen.hand.double_click(listing.row(home_path(KICK))) - screen.expect(screen.sequencer.playback.can_stop, bool, description="the recording playing") + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") assert listing.rows() == [listing.row(home_path(KICK))] def its_menu_leads_with_play(screen: Screen) -> None: diff --git a/tests/screens/main/mix/test_turning_to_a_mix.py b/tests/screens/main/mix/test_turning_to_a_mix.py index 154eee1a0..e3ad3d198 100644 --- a/tests/screens/main/mix/test_turning_to_a_mix.py +++ b/tests/screens/main/mix/test_turning_to_a_mix.py @@ -154,9 +154,10 @@ def open_the_question(screen: Screen) -> None: screen.expect(question.is_shown, bool, description="the question") def a_double_click_sounds_it(screen: Screen) -> None: + heard = screen.sound_heard() screen.hand.double_click(question.name(paths[0])) - screen.expect(screen.sequencer.playback.can_stop, bool, description="the recording sounding") + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") def names_clicked_leave_no_highlight(screen: Screen) -> None: before = [question.is_ticked(path) for path in paths] diff --git a/tests/screens/main/row_settings/test_pick.py b/tests/screens/main/row_settings/test_pick.py index 18667a43b..77c913f55 100644 --- a/tests/screens/main/row_settings/test_pick.py +++ b/tests/screens/main/row_settings/test_pick.py @@ -70,9 +70,10 @@ def pick_twice(screen: Screen) -> None: assert main.source.subject() == home_path(KICK).stem def a_double_click_plays_and_keeps_it(screen: Screen) -> None: + heard = screen.sound_heard() screen.hand.double_click(listing.row(home_path(KICK))) - screen.expect(playback.can_stop, bool, description="the recording playing") + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") assert listing.is_picked(home_path(KICK)) def esc_stops_and_keeps_it(screen: Screen) -> None: diff --git a/tests/screens/main/scan/test_reading_a_folder.py b/tests/screens/main/scan/test_reading_a_folder.py index b3ec61f30..3bc6bbabd 100644 --- a/tests/screens/main/scan/test_reading_a_folder.py +++ b/tests/screens/main/scan/test_reading_a_folder.py @@ -6,7 +6,7 @@ from sampletones_application.categories.hierarchy import Tab from tests.suite.screens.holds.base import Holds -from tests.suite.screens.holds.scan import ScanHold +from tests.suite.screens.holds.scan import ScanHold, WindingDownScanHold from tests.suite.screens.holds.signal import ReleaseSignal from tests.suite.screens.screen import Screen from tests.suite.screens.seeds.recordings import Recording @@ -22,8 +22,7 @@ REPORTED_COUNT: Final[int] = 128 SECONDS: Final[float] = 0.05 FREQUENCY: Final[float] = 330.0 -SLOW_ENTRY_SECONDS: Final[float] = 2.0 -SLOW_RELEASE_FILE: Final[str] = "release-slow-scan" +WIND_DOWN_RELEASE_FILE: Final[str] = "release-winding-down-scan" RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" @@ -176,12 +175,16 @@ def asking_again_reads_it(screen: Screen) -> None: class TestAskingForAFolderAsAStoppedReadWindsDown: - """A folder asked for once Stop closed the window is read, as the scan promises once its window closes.""" + """A folder asked for once Stop closed the window is read, as the scan promises once its window closes. + + The read of the large folder is held past its last entry, so Stop closes the window while the read still + winds down. The small folder is Ctrl-clicked then, and the hold lets the stopped read end. + """ @pytest.fixture - def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> ScanHold: - """A scan hold that releases slowly, so a stopped read still winds down when the next folder comes.""" - hold = ScanHold(ReleaseSignal(Path.cwd().parent / SLOW_RELEASE_FILE), interval=SLOW_ENTRY_SECONDS) + def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> WindingDownScanHold: + """A scan hold that keeps a stopped read winding down until the scenario releases it.""" + hold = WindingDownScanHold(ReleaseSignal(Path.cwd().parent / WIND_DOWN_RELEASE_FILE)) hold.install(monkeypatch) screen_holds.add(hold) return hold @@ -191,7 +194,7 @@ def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> Sca raises=AssertionError, reason="bugs-and-todos ยง Bugs: a folder asked for while a stopped read winds down is dropped", ) - def test_the_folder_asked_for_is_read(self, screen: Screen, scan_hold: ScanHold) -> None: + def test_the_folder_asked_for_is_read(self, screen: Screen, scan_hold: WindingDownScanHold) -> None: """The small folder, Ctrl-clicked just after Stop, is gathered once the hold is released.""" converter = screen.main.converter screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) diff --git a/tests/screens/reconstructions/instruments/test_audition.py b/tests/screens/reconstructions/instruments/test_audition.py index b183a246d..5d3abaecf 100644 --- a/tests/screens/reconstructions/instruments/test_audition.py +++ b/tests/screens/reconstructions/instruments/test_audition.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List import pytest @@ -11,7 +11,6 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.steps.project import leave_letting_the_project_go from tests.suite.screens.steps.reconstructions import expect_open -from tests.suite.screens.views.waveform import read_cursor from tests.suite.screens.vocabulary.playback import PLAY from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT @@ -61,9 +60,8 @@ def back_on_the_pulse(screen: Screen) -> None: class TestAnInstrumentsWaveform: """A click on an open instrument's waveform keeps playback silent, while a note key sounds it. - A reconstruction that plays stands open before the instrument, so any sound from a click is heard as - a moving cursor. The click leaves the cursor still and the Play button as it was; the note key moves - the cursor. + A reconstruction that plays stands open before the instrument, so a click that played would play it. + The click plays nothing and leaves the Play button as it was; the note key plays sound. """ @pytest.fixture @@ -74,26 +72,22 @@ def startup(self) -> Startup: def test_a_click_plays_nothing_and_a_key_does(self, screen: Screen) -> None: waveform = screen.reconstructions.waveform - def cursors_over(gesture: Callable[[], None]) -> List[Optional[float]]: - with screen.record(partial(read_cursor, waveform.cursor_line)) as recording: - gesture() - screen.frames(NOTE_FRAMES) + def heard_over(gesture: Callable[[], None]) -> bool: + heard = screen.sound_heard() + gesture() + screen.frames(NOTE_FRAMES) - return recording.values() + return screen.sound_heard() > heard def a_click_plays_nothing(screen: Screen) -> None: expect_open(screen, PLAYABLE_RECONSTRUCTION) give_it_a_volume(screen) screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) - cursors = cursors_over(partial(waveform.click, 0.5)) - - assert not any(cursors) + assert not heard_over(partial(waveform.click, 0.5)) assert screen.sequencer.playback.play_entry() == screen.words(PLAY) def a_note_key_sounds_it(screen: Screen) -> None: - cursors = cursors_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) - - assert any(cursors) + assert heard_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) screen.scenario(a_click_plays_nothing, a_note_key_sounds_it, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_note_keys.py b/tests/screens/reconstructions/instruments/test_note_keys.py index c5916f8e3..2dcb69c89 100644 --- a/tests/screens/reconstructions/instruments/test_note_keys.py +++ b/tests/screens/reconstructions/instruments/test_note_keys.py @@ -1,5 +1,4 @@ import operator -from functools import partial from typing import Final import pytest @@ -14,7 +13,6 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.steps.project import leave_letting_the_project_go from tests.suite.screens.steps.reconstructions import expect_open -from tests.suite.screens.views.waveform import read_cursor from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG PIANO_C_SHARP_UP: Final[int] = IMGUI_DIGIT_ZERO + 2 @@ -31,17 +29,17 @@ def startup(self) -> Startup: return Startup(reconstruction=None, project=SONG) def test_keys_sound_the_instrument_and_a_field_keeps_them(self, screen: Screen) -> None: - """Piano keys move the waveform cursor; with the Arpeggio field focused, the same key writes its - letter and sounds nothing. + """Piano keys play sound; with the Arpeggio field focused, the same key writes its letter and plays + nothing. """ instruments = screen.reconstructions.instruments def sounded(key: int) -> bool: - with screen.record(partial(read_cursor, screen.reconstructions.waveform.cursor_line)) as recording: - screen.hand.press_key(key, modifiers=[]) - screen.frames(NOTE_FRAMES) + heard = screen.sound_heard() + screen.hand.press_key(key, modifiers=[]) + screen.frames(NOTE_FRAMES) - return any(reading is not None and reading > 0 for reading in recording.values()) + return screen.sound_heard() > heard def the_keys_sound_it(screen: Screen) -> None: give_it_a_volume(screen) diff --git a/tests/suite/screens/application/boundaries.py b/tests/suite/screens/application/boundaries.py index 9b2dd0882..fd9e5777d 100644 --- a/tests/suite/screens/application/boundaries.py +++ b/tests/suite/screens/application/boundaries.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +from tests.suite.screens.boundaries.audio import OutputRecord from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords from tests.suite.screens.boundaries.highlights import TableHighlights @@ -18,3 +19,4 @@ class Boundaries: holds: Holds file_manager: FileManagerStandIn highlights: TableHighlights + output: OutputRecord diff --git a/tests/suite/screens/boundaries/audio.py b/tests/suite/screens/boundaries/audio.py index e7f608bab..68273d9a9 100644 --- a/tests/suite/screens/boundaries/audio.py +++ b/tests/suite/screens/boundaries/audio.py @@ -1,7 +1,9 @@ +import threading from enum import StrEnum from pathlib import Path -from typing import Final +from typing import Final, Optional +import numpy as np import pyaudio import pytest @@ -16,6 +18,47 @@ class OutputDevice(StrEnum): NONE = "none" +class OutputRecord: + """What the application wrote to its output device: how many of the samples it played carry sound. + + It wraps the write of every output stream, so a sound counts once it reaches the device, however briefly it + plays and however many frames pass between two readings of it. The playback thread writes while a + scenario reads, so the count stands behind a lock. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sounding_samples = 0 + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Puts the record in front of every output stream's write before the application starts. + + ``PyAudio.open`` builds its streams from ``PyAudio.Stream``, the class whose write is wrapped. + """ + write = pyaudio.PyAudio.Stream.write + + def recorded( + stream: pyaudio.PyAudio.Stream, + frames: bytes, + num_frames: Optional[int] = None, + exception_on_underflow: bool = False, + ) -> None: + self._count(frames) + write(stream, frames, num_frames, exception_on_underflow) + + monkeypatch.setattr(pyaudio.PyAudio.Stream, "write", recorded) + + def sounding_samples(self) -> int: + """How many samples carrying sound the application has written to its output so far.""" + with self._lock: + return self._sounding_samples + + def _count(self, frames: bytes) -> None: + sounding = int(np.count_nonzero(np.frombuffer(frames, dtype=np.float32))) + with self._lock: + self._sounding_samples += sounding + + def provide_output_device( device: OutputDevice, *, diff --git a/tests/suite/screens/dearpygui/bridge.py b/tests/suite/screens/dearpygui/bridge.py index fae91c409..e32c94211 100644 --- a/tests/suite/screens/dearpygui/bridge.py +++ b/tests/suite/screens/dearpygui/bridge.py @@ -1,3 +1,4 @@ +import math import threading import time from concurrent.futures import Future @@ -7,6 +8,7 @@ NEXT_DRAIN: Final[int] = 0 ONE_FRAME: Final[int] = 1 POLL_SECONDS: Final[float] = 0.05 +REFERENCE_FRAME_RATE: Final[int] = 30 class RenderThreadTimeoutError(AssertionError): @@ -39,13 +41,44 @@ def post( def is_running(self) -> bool: """Whether the render thread still draws frames, and so still runs what is posted to it.""" + def frames_drawn(self) -> int: + """How many frames the render thread has drawn, read from any thread.""" + + +class Deadline: + """The end of a wait, which comes once its seconds have run and the render thread has drawn its frames. + + The frames are those a machine drawing ``REFERENCE_FRAME_RATE`` frames a second draws in the same seconds. A + machine drawing more slowly answers each gesture in more seconds, so its waits stretch with its frames, and a + wait fails on what the application did within them. A render thread that stops ends every wait. + """ + + def __init__( + self, + render_thread: RenderThread, + *, + seconds: float, + ) -> None: + self._render_thread = render_thread + self.seconds = seconds + self.frames = math.ceil(seconds * REFERENCE_FRAME_RATE) + self._end = time.monotonic() + seconds + self._last_frame = render_thread.frames_drawn() + self.frames + + def passed(self) -> bool: + """Whether both the seconds and the frames of the wait have run, or the render thread stopped.""" + if not self._render_thread.is_running(): + return True + + return time.monotonic() >= self._end and self._render_thread.frames_drawn() >= self._last_frame + class Bridge: """The crossing a scenario takes to the render thread, where every reading and gesture happens. A question runs between two frames, so what it reads is one consistent frame. Its answer, or the exception it raised, comes back to the scenario's thread. Waiting is counted in frames drawn, and - an expectation is read once a frame until it holds or its time runs out. + an expectation is read once a frame until it holds or its `Deadline` passes. """ def __init__( @@ -104,19 +137,27 @@ def expect( may combine several answers. Raises: - ExpectationError: If no reading within ``timeout`` seconds holds, naming the last one. + ExpectationError: If no reading holds before the deadline of ``timeout`` seconds passes, naming the + last one. """ - deadline = time.monotonic() + timeout + deadline = self.deadline(timeout) while True: value = reading() if holds(value): return value - if time.monotonic() >= deadline: - raise ExpectationError(f"Expected {description} within {timeout} s; the last reading was {value!r}") + if deadline.passed(): + raise ExpectationError( + f"Expected {description} within {timeout} s and {deadline.frames} frames; " + f"the last reading was {value!r}" + ) self.frames(ONE_FRAME) + def deadline(self, seconds: float) -> Deadline: + """The deadline of a wait of ``seconds`` that starts now.""" + return Deadline(self._render_thread, seconds=seconds) + def _wait( self, event: threading.Event, diff --git a/tests/suite/screens/dearpygui/gestures/arrival.py b/tests/suite/screens/dearpygui/gestures/arrival.py index aea0e4c4e..ab6da7537 100644 --- a/tests/suite/screens/dearpygui/gestures/arrival.py +++ b/tests/suite/screens/dearpygui/gestures/arrival.py @@ -1,4 +1,3 @@ -import time from contextlib import suppress from functools import partial from typing import Callable, Final, Optional, Sequence @@ -9,6 +8,7 @@ from tests.suite.screens.dearpygui.geometry import Point from tests.suite.screens.dearpygui.gestures.constants import REACH_TIMEOUT_SECONDS from tests.suite.screens.dearpygui.gestures.errors import GestureLostError +from tests.suite.screens.dearpygui.gestures.witness import InputWitness from tests.suite.screens.dearpygui.items.reading import read_hovered from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.dearpygui.items.viewport import read_pointer, read_viewport @@ -23,10 +23,11 @@ class Arrival: """The part of a hand that lets frames pass and confirms that its input reached DearPyGui. - It owns the bridge, the display's input device and the time of the last release. It aims at an item - once the item stands in reach and holds still, waits out a double-click window, and confirms - that the pointer, a button or a key reads as held. `Pointer` and `Keyboard` build their gestures - on these steps, and `Scrolling` reaches them through `Pointer`. + It owns the bridge, the display's input device, the witness of what Dear ImGui counted and the time of + the last release. It aims at an item once the item stands in reach and holds still, waits out a + double-click window, confirms that the pointer, a held button or a held key reads where it was put, and + confirms a press that came and went from the witness's counts. `Pointer` and `Keyboard` build their + gestures on these steps, and `Scrolling` reaches them through `Pointer`. """ def __init__( @@ -36,6 +37,7 @@ def __init__( ) -> None: self._bridge = bridge self._device = device + self._witness = InputWitness() self._last_release: Optional[float] = None def _settle(self, count: int) -> None: @@ -85,19 +87,19 @@ def read() -> Point: y=round(viewport.y) + center.y, ) - deadline = time.monotonic() + REACH_TIMEOUT_SECONDS + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) previous: Optional[Point] = None while True: try: point = self._bridge.ask(read) except UnreachableError: - if time.monotonic() >= deadline: + if deadline.passed(): raise point = None if point is not None and point == previous: return point - if time.monotonic() >= deadline: + if deadline.passed(): raise UnreachableError(f"{item!r} kept moving: it stood at {previous} and then at {point}") previous = point @@ -136,6 +138,37 @@ def _confirm_pointer(self, point: Point) -> None: raise GestureLostError(f"The pointer was moved to {expected} and the application reads it elsewhere") + def _await_count( + self, + count: Callable[[], int], + target: int, + what: str, + ) -> bool: + """Waits a few frames until the witness's ``count`` reaches ``target``, and says whether the application + still runs. + + A display busy with other work hands input on a few frames late, so the count is read once a frame + for a few frames. A gesture that closes the application ends the wait where the application stopped. + + Raises: + GestureLostError: If the count stays short of ``target`` throughout. + """ + counted = 0 + for _ in range(ARRIVAL_FRAMES): + try: + counted = self._bridge.ask(count) + except RenderThreadStoppedError: + return False + if counted >= target: + return True + + self._settle(ONE_FRAME) + + raise GestureLostError( + f"{what} was pressed on the display, and the application counted {counted} of the {target} " + f"releases the press brings it to" + ) + def _confirm_keys(self, keys: Sequence[int]) -> None: for key in keys: self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") diff --git a/tests/suite/screens/dearpygui/gestures/errors.py b/tests/suite/screens/dearpygui/gestures/errors.py index 6e4192d29..5ad2907d0 100644 --- a/tests/suite/screens/dearpygui/gestures/errors.py +++ b/tests/suite/screens/dearpygui/gestures/errors.py @@ -1,2 +1,6 @@ class GestureLostError(AssertionError): """Raised when a gesture's input never reached the control it was aimed at.""" + + +class SlowFramesError(GestureLostError): + """Raised when the display draws its frames too slowly for Dear ImGui to read a gesture the way a person meant it.""" diff --git a/tests/suite/screens/dearpygui/gestures/keyboard.py b/tests/suite/screens/dearpygui/gestures/keyboard.py index 69eccde0b..b47cd5c65 100644 --- a/tests/suite/screens/dearpygui/gestures/keyboard.py +++ b/tests/suite/screens/dearpygui/gestures/keyboard.py @@ -1,15 +1,19 @@ +from functools import partial from typing import Sequence +from tests.suite.screens.dearpygui.bridge import ONE_FRAME from tests.suite.screens.dearpygui.gestures.arrival import Arrival -from tests.suite.screens.dearpygui.gestures.constants import HOLD_FRAMES, RELEASE_FRAMES, SETTLE_FRAMES -from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT, keysym_of, keysym_of_character +from tests.suite.screens.dearpygui.gestures.constants import HOLD_FRAMES, SETTLE_FRAMES +from tests.suite.screens.dearpygui.keys import IMGUI_LEFT_SHIFT, key_name, keysym_of, keysym_of_character class Keyboard(Arrival): """The keyboard of a hand: key presses with modifiers, and typed text. - It owns every gesture made with keys and builds each from `Arrival`'s steps: key down, confirm the - key reads as held, key up and settle. The full hand also uses it to replace the text of a field. + It owns every gesture made with keys and builds each from `Arrival`'s steps. Modifiers go down first and + read as held, and the key itself goes down and comes up in one go, so Dear ImGui reads it down for a + single frame at any frame rate, the one press a person made, and the witness confirms its release. The + full hand also uses it to replace the text of a field. """ def press_key( @@ -21,17 +25,18 @@ def press_key( """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" for modifier in modifiers: self._device.key_down(keysym_of(modifier)) - if modifiers: - self._settle(HOLD_FRAMES) - self._confirm_keys(modifiers) - - self._device.key_down(keysym_of(key)) - self._settle(HOLD_FRAMES) - self._confirm_keys([key]) - self._device.key_up(keysym_of(key)) - self._settle(RELEASE_FRAMES) - for modifier in reversed(modifiers): - self._device.key_up(keysym_of(modifier)) + try: + if modifiers: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + + released = partial(self._witness.key_releases, key) + before = self._bridge.ask(released) + self._device.tap_key(keysym_of(key)) + self._await_count(released, before + 1, f"The key {key_name(key)}") + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) self._settle(SETTLE_FRAMES) @@ -43,15 +48,11 @@ def type_text(self, text: str) -> None: shifted = self._device.needs_shift(keysym) if shifted: self._device.key_down(shift) - - self._tap(keysym) - if shifted: - self._device.key_up(shift) + try: + self._device.tap_key(keysym) + self._settle(ONE_FRAME) + finally: + if shifted: + self._device.key_up(shift) self._settle(SETTLE_FRAMES) - - def _tap(self, keysym: int) -> None: - self._device.key_down(keysym) - self._settle(HOLD_FRAMES) - self._device.key_up(keysym) - self._settle(RELEASE_FRAMES) diff --git a/tests/suite/screens/dearpygui/gestures/pointer.py b/tests/suite/screens/dearpygui/gestures/pointer.py index 8d7174866..c77cb2502 100644 --- a/tests/suite/screens/dearpygui/gestures/pointer.py +++ b/tests/suite/screens/dearpygui/gestures/pointer.py @@ -1,11 +1,10 @@ -import time -from typing import Dict, Final, Sequence +from typing import Dict, Final, Sequence, Tuple import dearpygui.dearpygui as dpg from tests.suite.screens.dearpygui.bridge import ONE_FRAME from tests.suite.screens.dearpygui.geometry import Point -from tests.suite.screens.dearpygui.gestures.arrival import Arrival +from tests.suite.screens.dearpygui.gestures.arrival import IMGUI_DOUBLE_CLICK_SECONDS, Arrival from tests.suite.screens.dearpygui.gestures.constants import ( HOLD_FRAMES, HOVER_FRAMES, @@ -13,7 +12,7 @@ RELEASE_FRAMES, SETTLE_FRAMES, ) -from tests.suite.screens.dearpygui.gestures.errors import GestureLostError +from tests.suite.screens.dearpygui.gestures.errors import GestureLostError, SlowFramesError from tests.suite.screens.dearpygui.items.types import Item from tests.suite.screens.dearpygui.keys import keysym_of from tests.suite.screens.dearpygui.xtest import MouseButton @@ -35,8 +34,11 @@ class Pointer(Arrival): """The pointer of a hand: presses, double-clicks, drags and hovering. It owns every gesture made with the buttons and builds each from `Arrival`'s steps: aim at the item, - rest over it, press, confirm the press and settle. `Scrolling` reuses its hover and drag to reach - scrollbars and the wheel. + rest over it, press, confirm the press and settle. A click goes down and comes up in one go, and so do + the presses of a double-click, so Dear ImGui reads each press on a frame of its own at any frame rate. + The presses of a double-click then land two frames apart, and Dear ImGui counts them as one double-click + while those frames take less than its double-click time between them. `Scrolling` reuses its hover and + drag to reach scrollbars and the wheel. """ def click(self, item: Item) -> None: @@ -56,13 +58,14 @@ def click_holding( """ for modifier in modifiers: self._device.key_down(keysym_of(modifier)) - self._settle(HOLD_FRAMES) - self._confirm_keys(modifiers) + try: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) - self._press(item, MouseButton.LEFT, SINGLE_PRESS) - - for modifier in reversed(modifiers): - self._device.key_up(keysym_of(modifier)) self._settle(SETTLE_FRAMES) def double_click(self, item: Item) -> None: @@ -83,8 +86,21 @@ def double_click_held( """ self.hover(item) self._outlast_a_double_click() - self._press_once(MouseButton.LEFT, HOLD_FRAMES) - self._press_once(MouseButton.LEFT, frames) + imgui_button = IMGUI_MOUSE_BUTTONS[MouseButton.LEFT] + releases, double_clicks = self._bridge.ask(lambda: self._counts(imgui_button)) + self._device.click_button(MouseButton.LEFT, SINGLE_PRESS) + self._device.button_down(MouseButton.LEFT) + try: + self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), "The left button") + self._require_double_click(imgui_button, releases, double_clicks) + self._settle(frames) + finally: + self._device.button_up(MouseButton.LEFT) + self._await_count( + lambda: self._witness.button_releases(imgui_button), + releases + DOUBLE_PRESS, + "The left button", + ) self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) @@ -134,13 +150,13 @@ def hover(self, item: Item) -> None: Raises: GestureLostError: If the item never reports the pointer resting on it. """ - deadline = time.monotonic() + REACH_TIMEOUT_SECONDS + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) while True: self._device.move(self._aim(item)) self._bridge.frames(HOVER_FRAMES) if self._hovered_within(item, HOVER_ARRIVAL_FRAMES): return - if time.monotonic() >= deadline: + if deadline.passed(): raise GestureLostError( f"The pointer was aimed at {item!r} and the item reports no hover: " f"something covers it, or the region it scrolls in clips it" @@ -154,24 +170,59 @@ def _press( ) -> None: self.hover(item) self._outlast_a_double_click() - for _ in range(count): - self._press_once(button, HOLD_FRAMES) - + self._press_where_it_stands(button, count) self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) - def _press_once( + def _press_where_it_stands( self, button: MouseButton, - frames: int, + count: int, ) -> None: - """Holds ``button`` down where the pointer stands for ``frames`` frames, then lets it go.""" + """Presses ``button`` ``count`` times in one go where the pointer stands, and confirms Dear ImGui read + every release, and the double-click when the presses are more than one. + """ imgui_button = IMGUI_MOUSE_BUTTONS[button] - self._device.button_down(button) - self._settle(frames) - self._confirm(lambda: dpg.is_mouse_button_down(imgui_button), f"The {button.name.lower()} button") - self._device.button_up(button) - self._settle(RELEASE_FRAMES) + releases, double_clicks = self._bridge.ask(lambda: self._counts(imgui_button)) + self._device.click_button(button, count) + running = self._await_count( + lambda: self._witness.button_releases(imgui_button), + releases + count, + f"The {button.name.lower()} button", + ) + if running and count > SINGLE_PRESS: + self._require_double_click(imgui_button, releases, double_clicks) + + def _counts(self, imgui_button: int) -> Tuple[int, int]: + """The releases and the double-clicks the witness counted of ``imgui_button`` so far.""" + return ( + self._witness.button_releases(imgui_button), + self._witness.double_clicks(imgui_button), + ) + + def _require_double_click( + self, + imgui_button: int, + releases: int, + double_clicks: int, + ) -> None: + """Checks that Dear ImGui counted a double-click since the witness counted ``double_clicks`` of them. + + The presses came two frames apart, so a missing double-click says the display drew those frames + more slowly than Dear ImGui's double-click time allows. + + Raises: + SlowFramesError: If Dear ImGui counted the presses as separate clicks. + """ + if self._bridge.ask(lambda: self._witness.double_clicks(imgui_button)) > double_clicks: + return + + times = self._bridge.ask(lambda: self._witness.button_release_times(imgui_button))[releases:] + took = f"{times[1] - times[0]:.2f} s" if len(times) > 1 else "longer than that" + raise SlowFramesError( + f"Dear ImGui counted no double-click: it counts two presses landing within " + f"{IMGUI_DOUBLE_CLICK_SECONDS} s of each other, and the two frames between these took {took}" + ) def _press_at( self, @@ -181,9 +232,7 @@ def _press_at( ) -> None: self.move_to(point) self._outlast_a_double_click() - for _ in range(count): - self._press_once(button, HOLD_FRAMES) - + self._press_where_it_stands(button, count) self._last_release = self._application_seconds() self._settle(SETTLE_FRAMES) @@ -198,18 +247,21 @@ def _drag_between(self, start: Point, end: Point) -> None: self._device.move(start) self._settle(HOVER_FRAMES) self._device.button_down(MouseButton.LEFT) - self._settle(HOLD_FRAMES) - self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") - - for step in range(1, DRAG_STEPS + 1): - point = Point( - x=start.x + round((end.x - start.x) * step / DRAG_STEPS), - y=start.y + round((end.y - start.y) * step / DRAG_STEPS), - ) - self._device.move(point) - self._settle(ONE_FRAME) - self._confirm_pointer(point) - - self._settle(HOLD_FRAMES) - self._device.button_up(MouseButton.LEFT) + try: + self._settle(HOLD_FRAMES) + self._confirm(lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), "The left button") + + for step in range(1, DRAG_STEPS + 1): + point = Point( + x=start.x + round((end.x - start.x) * step / DRAG_STEPS), + y=start.y + round((end.y - start.y) * step / DRAG_STEPS), + ) + self._device.move(point) + self._settle(ONE_FRAME) + self._confirm_pointer(point) + + self._settle(HOLD_FRAMES) + finally: + self._device.button_up(MouseButton.LEFT) + self._settle(RELEASE_FRAMES) diff --git a/tests/suite/screens/dearpygui/gestures/witness.py b/tests/suite/screens/dearpygui/gestures/witness.py new file mode 100644 index 000000000..f7b48f620 --- /dev/null +++ b/tests/suite/screens/dearpygui/gestures/witness.py @@ -0,0 +1,62 @@ +from collections import Counter +from typing import Dict, Final, List, Optional, Tuple, Union + +import dearpygui.dearpygui as dpg + +ANY_KEY: Final[int] = 0 +ANY_BUTTON: Final[int] = -1 + + +class InputWitness: + """What Dear ImGui counted of the display's input: each release of a key or a button, and each double-click. + + It is a global handler registry of its own in the application's DearPyGui context, so its counts grow as + the application's frames read the input, and a count stays once a press has come and gone. A hand reads + them to confirm a press that stood down for a single frame. Every call runs on the render thread, and the + first call puts the registry in place. + """ + + def __init__(self) -> None: + self._registry: Optional[Union[int, str]] = None + self._key_releases: Counter[int] = Counter() + self._button_release_times: Dict[int, List[float]] = {} + self._double_clicks: Counter[int] = Counter() + + def key_releases(self, key: int) -> int: + """How many times Dear ImGui read the Dear ImGui key ``key`` coming up.""" + self._install() + return self._key_releases[key] + + def button_releases(self, button: int) -> int: + """How many times Dear ImGui read the Dear ImGui mouse button ``button`` coming up.""" + self._install() + return len(self._button_release_times.get(button, ())) + + def button_release_times(self, button: int) -> Tuple[float, ...]: + """The application's clock at each release of the Dear ImGui mouse button ``button``, oldest first.""" + self._install() + return tuple(self._button_release_times.get(button, ())) + + def double_clicks(self, button: int) -> int: + """How many double-clicks Dear ImGui counted with the Dear ImGui mouse button ``button``.""" + self._install() + return self._double_clicks[button] + + def _install(self) -> None: + if self._registry is not None and dpg.does_item_exist(self._registry): + return + + with dpg.handler_registry() as registry: + dpg.add_key_release_handler(key=ANY_KEY, callback=self._on_key_released) + dpg.add_mouse_release_handler(button=ANY_BUTTON, callback=self._on_button_released) + dpg.add_mouse_double_click_handler(button=ANY_BUTTON, callback=self._on_double_clicked) + self._registry = registry + + def _on_key_released(self, _sender: Union[int, str], key: int) -> None: + self._key_releases[key] += 1 + + def _on_button_released(self, _sender: Union[int, str], button: int) -> None: + self._button_release_times.setdefault(button, []).append(float(dpg.get_total_time())) + + def _on_double_clicked(self, _sender: Union[int, str], button: int) -> None: + self._double_clicks[button] += 1 diff --git a/tests/suite/screens/dearpygui/hand.py b/tests/suite/screens/dearpygui/hand.py index 522e6327e..c5bd55dc1 100644 --- a/tests/suite/screens/dearpygui/hand.py +++ b/tests/suite/screens/dearpygui/hand.py @@ -10,13 +10,17 @@ class Hand(Scrolling, Keyboard): A gesture first reads where its control stands, refusing one beyond a user's reach, and then plays the events a person makes. Every step waits whole frames, since the application reads input once a frame: the pointer rests over a control before pressing it, and a modifier is held - a frame before the key it modifies goes down. A gesture that closes the application ends where - the application stopped, which is how a scenario presses the button that leaves it. + a frame before the key it modifies goes down. A click and a key press go down and come up in one + go, which Dear ImGui reads over two frames, so a press means what a person meant at any frame + rate: a key stands down too briefly to repeat, and the presses of a double-click land two frames + apart. A gesture that closes the application ends where the application stopped, which is how a + scenario presses the button that leaves it. A gesture confirms that it arrived. The control reports the pointer resting on it before a - button goes down, and the application reports every held button and named key down while it is - held, so a press lost on the way fails where it was lost, and a scenario expecting nothing to - happen learns that its gesture was made. + button goes down, a held button or modifier reads as down while it is held, and Dear ImGui + counts the release of every click and key press and the double-click of a double press. A press + lost on the way fails where it was lost, and a scenario expecting nothing to happen learns that + its gesture was made. The pieces are `Arrival` (waiting and confirming), `Pointer` (presses and drags), `Scrolling` (the wheel and the scrollbars) and `Keyboard` (keys and typed text). diff --git a/tests/suite/screens/dearpygui/xtest.py b/tests/suite/screens/dearpygui/xtest.py index 27da498e4..00586944b 100644 --- a/tests/suite/screens/dearpygui/xtest.py +++ b/tests/suite/screens/dearpygui/xtest.py @@ -8,6 +8,7 @@ from tests.suite.screens.dearpygui.geometry import Point SHIFTED_COLUMN: Final[int] = 1 +BITS_PER_BYTE: Final[int] = 8 class MouseButton(IntEnum): @@ -19,6 +20,11 @@ class MouseButton(IntEnum): WHEEL_UP = 4 WHEEL_DOWN = 5 + @property + def mask(self) -> int: + """The bit the pointer's state carries while this button is down.""" + return X.Button1Mask << (self.value - 1) + class UntypableCharacterError(LookupError): """Raised when the display's keyboard map holds no key that types a character.""" @@ -27,8 +33,10 @@ class UntypableCharacterError(LookupError): class XTestDevice: """The pointer and the keyboard of one X display, driven through the XTEST extension. - Each call sends one event and flushes it, so the server holds it before the next frame polls. - The display is the scenario's own, which keeps every event inside the application under test. + Each call sends its events and flushes them, so the server holds them before the next frame polls. + The display belongs to the worker, which keeps every event inside the application under test, and + the worker's scenarios take it in turn. The device therefore starts and ends with every key and + button up, so a scenario that failed halfway through a gesture leaves nothing held for the next. The display turns key auto-repeat off, so a key held across a slow frame arrives as the one press a person made. """ @@ -36,7 +44,7 @@ class XTestDevice: def __init__(self, display_name: str) -> None: self._display = Display(display_name) self._display.change_keyboard_control(auto_repeat_mode=X.AutoRepeatModeOff) - self._display.sync() + self.release_everything() def move(self, point: Point) -> None: """Moves the pointer to ``point``, in display pixels.""" @@ -53,6 +61,32 @@ def button_up(self, button: MouseButton) -> None: xtest.fake_input(self._display, X.ButtonRelease, int(button)) self._display.sync() + def click_button( + self, + button: MouseButton, + presses: int, + ) -> None: + """Presses and releases ``button`` ``presses`` times in one go. + + Dear ImGui reads each press and each release on a frame of its own, so a press stands down for a + single frame and the presses land two frames apart, the closest a display brings them. + """ + for _ in range(presses): + xtest.fake_input(self._display, X.ButtonPress, int(button)) + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def tap_key(self, keysym: int) -> None: + """Presses and releases the key typing ``keysym`` in one go. + + Dear ImGui reads the press on one frame and the release on the next, so the key stands down for a + single frame at any frame rate, too briefly to repeat. + """ + keycode = self._keycode(keysym) + xtest.fake_input(self._display, X.KeyPress, keycode) + xtest.fake_input(self._display, X.KeyRelease, keycode) + self._display.sync() + def key_down(self, keysym: int) -> None: """Presses the key typing ``keysym`` and holds it down.""" xtest.fake_input(self._display, X.KeyPress, self._keycode(keysym)) @@ -70,8 +104,22 @@ def needs_shift(self, keysym: int) -> bool: self._display.keycode_to_keysym(keycode, SHIFTED_COLUMN) == keysym ) + def release_everything(self) -> None: + """Lets go of every key and button the display holds down, whichever gesture pressed it.""" + for index, byte in enumerate(self._display.query_keymap()): + for bit in range(BITS_PER_BYTE): + if byte & (1 << bit): + xtest.fake_input(self._display, X.KeyRelease, index * BITS_PER_BYTE + bit) + + mask = self._display.screen().root.query_pointer().mask + for button in MouseButton: + if mask & button.mask: + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + def close(self) -> None: - """Closes the connection to the display.""" + """Lets go of everything still held and closes the connection to the display.""" + self.release_everything() self._display.close() def _keycode(self, keysym: int) -> int: diff --git a/tests/suite/screens/holds/scan.py b/tests/suite/screens/holds/scan.py index 2366b844c..18645f830 100644 --- a/tests/suite/screens/holds/scan.py +++ b/tests/suite/screens/holds/scan.py @@ -5,6 +5,7 @@ import pytest import sampletones_application.logic.main.sources.scan as scan_module +from tests.suite.screens.holds.constants import RELEASE_POLL_SECONDS from tests.suite.screens.holds.signal import ReleaseSignal HELD_ENTRY: Final[str] = "held-entry.txt" @@ -42,3 +43,29 @@ def _walk(self, root: Path) -> Iterator[Path]: while not self._signal.is_released(): yield root / HELD_ENTRY time.sleep(self._interval) + + +class WindingDownScanHold: + """Stands in for the walk a folder scan reads, keeping it past its last entry until released. + + The walk meets every entry the folder holds and then waits for the scenario, meeting no further entry. A + reader's Stop is checked between entries, so a stopped read winds down for as long as the scenario holds + it, and a folder asked for meanwhile arrives while it winds down on any machine. + """ + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the folder walk of a scan with the held one.""" + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + """Lets the held walk end.""" + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + """Yields every entry below ``root``, then waits for the release before the walk ends.""" + yield from root.rglob("*") + while not self._signal.is_released(): + time.sleep(RELEASE_POLL_SECONDS) diff --git a/tests/suite/screens/plugin/fixtures.py b/tests/suite/screens/plugin/fixtures.py index 42583d384..bc7a564ea 100644 --- a/tests/suite/screens/plugin/fixtures.py +++ b/tests/suite/screens/plugin/fixtures.py @@ -9,7 +9,7 @@ from tests.suite.screens.application.boundaries import Boundaries from tests.suite.screens.application.running import ScreenApplication from tests.suite.screens.application.startup import Startup -from tests.suite.screens.boundaries.audio import OutputDevice, provide_output_device +from tests.suite.screens.boundaries.audio import OutputDevice, OutputRecord, provide_output_device from tests.suite.screens.boundaries.dialogs import ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords from tests.suite.screens.boundaries.highlights import TableHighlights @@ -88,6 +88,8 @@ def screen_boundaries( spawns.allow(file_manager.folder) highlights = TableHighlights() highlights.install(monkeypatch) + output = OutputRecord() + output.install(monkeypatch) provide_output_device( output_device, home=Path.home(), @@ -100,6 +102,7 @@ def screen_boundaries( holds=screen_holds, file_manager=file_manager, highlights=highlights, + output=output, ) @@ -150,6 +153,7 @@ def screen( errors=screen_boundaries.errors, file_manager=screen_boundaries.file_manager, highlights=screen_boundaries.highlights, + output=screen_boundaries.output, artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), ) window_manager.close() diff --git a/tests/suite/screens/plugin/hooks.py b/tests/suite/screens/plugin/hooks.py index cd0a2c582..5718f1bf7 100644 --- a/tests/suite/screens/plugin/hooks.py +++ b/tests/suite/screens/plugin/hooks.py @@ -12,7 +12,7 @@ from tests.suite.screens.plugin.constants import DISPLAY_KEY from tests.suite.screens.plugin.fixtures import _worker_display -CHILD_TIMEOUT_SECONDS: Final[float] = 180.0 +CHILD_TIMEOUT_SECONDS: Final[float] = 600.0 JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" diff --git a/tests/suite/screens/render_thread.py b/tests/suite/screens/render_thread.py index f565b7140..604e5d418 100644 --- a/tests/suite/screens/render_thread.py +++ b/tests/suite/screens/render_thread.py @@ -28,6 +28,10 @@ def is_running(self) -> bool: """Whether the loop is drawing.""" return self._running.is_set() + def frames_drawn(self) -> int: + """How many frames the loop has drawn, counted by the queue it drains.""" + return CallbackQueue.current_frame() + def start(self) -> None: """Marks the loop as drawing, which is when posted work starts being answered.""" self._running.set() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index a46dce0ec..f57c839c4 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -20,6 +20,7 @@ from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from sampletones_core.constants.enums import ChannelName, GeneratorName from tests.suite.scenario import BaseTestScenario, ScenarioStep +from tests.suite.screens.boundaries.audio import OutputRecord from tests.suite.screens.boundaries.dialogs import DialogKind, DialogRequest, ScriptedFileDialogs from tests.suite.screens.boundaries.errors import ErrorRecords from tests.suite.screens.boundaries.highlights import HighlightPlace, TableHighlights @@ -83,6 +84,7 @@ def __init__( errors: ErrorRecords, file_manager: FileManagerStandIn, highlights: TableHighlights, + output: OutputRecord, artifacts: Path, ) -> None: self.bridge = bridge @@ -94,6 +96,7 @@ def __init__( self._errors = errors self._file_manager = file_manager self._highlights = highlights + self._output = output self._artifacts = artifacts self._language = language self.tabs = Tabs(bridge, hand) @@ -222,6 +225,14 @@ def wait_for_exit(self) -> bool: """Waits for the application to stop, and says whether it did within the expectation's time.""" return self._render_thread.wait_stopped(EXPECT_TIMEOUT_SECONDS) + def sound_heard(self) -> int: + """How many samples carrying sound the application has played so far, which grows while anything sounds. + + The count is taken at the output device, so a sound shorter than the frames between two readings counts + all the same. + """ + return self._output.sounding_samples() + def dialog_requests(self) -> Tuple[DialogRequest, ...]: """Every file dialog the application opened so far, in order.""" return self._dialogs.requests diff --git a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py index 31bdd1ed1..5d5b976fc 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py @@ -110,6 +110,14 @@ def test_zero_delay_callback_is_due_at_current_frame(self) -> None: assert fired == [True] + def test_current_frame_counts_each_frame_drawn(self) -> None: + start = CallbackQueue.current_frame() + + CallbackQueue.notify_frame() + CallbackQueue.notify_frame() + + assert CallbackQueue.current_frame() == start + 2 + class TestCallbackQueueBudget: def test_zero_budget_runs_one_due_callback_per_call(self) -> None: